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
+23
View File
@@ -0,0 +1,23 @@
# Server SSR del sito pubblico (apps/public). Tutte facoltative in sviluppo: i default sono in
# src/environments/environment.ts. In produzione arrivano dal docker-compose (prompt 33).
# Il server le legge da process.env (e da questo file se copiato in .env nella cartella di avvio).
# URL pubblico del sito, senza slash finale (canonical, Open Graph, sitemap)
SITE_URL=http://localhost:4200
# URL dell'API Fastify visto dal browser
PUBLIC_API_URL=http://localhost:3000
# URL dell'API usato dal server SSR (in Docker: http://api:3000); default PUBLIC_API_URL
API_INTERNAL_URL=
# Origine delle immagini pubbliche (blog, prodotti) per la Content-Security-Policy; default PUBLIC_API_URL
PUBLIC_MEDIA_URL=
# Area riservata (apps/private)
PRIVATE_APP_URL=http://localhost:4300
# true solo in produzione: altrimenti noindex su tutte le pagine e robots.txt "Disallow: /"
SITE_INDEXABLE=false
# Chiave PUBBLICABILE Stripe (pk_test_… in sviluppo, pk_live_… in produzione) per il Payment Element del checkout.
# Stesso account Stripe della STRIPE_API_KEY di Medusa. Vuota = checkout senza pagamento (messaggio di servizio).
STRIPE_PUBLISHABLE_KEY=
# Porta del server Node (node dist/public/server/server.mjs)
PORT=4000
# Host ammessi dal server SSR (protezione Angular contro host header spoofing), separati da virgola
NG_ALLOWED_HOSTS=
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
# Sito pubblico Angular SSR (apps/public). Contesto di build: radice del monorepo.
# docker build -f apps/public/Dockerfile -t iti-technology/public .
# Il server SSR (dist/public/server/server.mjs) contiene già tutte le dipendenze: il runtime non ha node_modules.
# La configurazione (dominio, URL dell'API, chiave Stripe…) arriva dalle variabili d'ambiente all'avvio.
ARG NODE_IMAGE=node:24-bookworm-slim
FROM ${NODE_IMAGE} AS deps
WORKDIR /repo
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/api/package.json apps/api/
COPY apps/public/package.json apps/public/
COPY apps/private/package.json apps/private/
RUN npm ci --ignore-scripts --no-audit --no-fund -w @iti/public -w @iti/shared --include-workspace-root
FROM deps AS build
COPY packages/shared packages/shared
COPY packages/styles packages/styles
COPY apps/public apps/public
# prebuild: indice dei contenuti + asset di Leaflet
RUN npm run build -w @iti/shared && npm run build -w @iti/public
FROM ${NODE_IMAGE} AS runtime
ENV NODE_ENV=production PORT=4000
WORKDIR /app
COPY --from=build /repo/apps/public/dist/public ./dist/public
USER node
EXPOSE 4000
# /robots.txt è una route Express (prima del rendering Angular): non dipende dall'API né dall'host
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD ["node", "-e", "fetch('http://127.0.0.1:4000/robots.txt').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
CMD ["node", "dist/public/server/server.mjs"]
+95
View File
@@ -0,0 +1,95 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"public": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"localhost",
"127.0.0.1"
]
},
"ssr": {
"entry": "src/server.ts"
},
"stylePreprocessorOptions": {
"includePaths": [
"../../packages/styles"
]
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "380kB",
"maximumError": "600kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kB",
"maximumError": "10kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "public:build:production"
},
"development": {
"buildTarget": "public:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@iti/public",
"version": "0.0.0",
"scripts": {
"content": "node scripts/content-index.mjs",
"postinstall": "node scripts/content-index.mjs && node scripts/vendor-assets.mjs",
"predev": "node scripts/content-index.mjs && node scripts/vendor-assets.mjs",
"dev": "ng serve --port 4200",
"prebuild": "node scripts/content-index.mjs && node scripts/vendor-assets.mjs",
"build": "ng build",
"start": "node dist/public/server/server.mjs",
"pretest": "node scripts/content-index.mjs",
"test": "ng test --watch=false",
"pretypecheck": "node scripts/content-index.mjs",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.spec.json --noEmit",
"ng": "ng"
},
"private": true,
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/platform-server": "^22.2.0",
"@angular/router": "^22.2.0",
"@angular/ssr": "^22.2.1",
"@iti/shared": "*",
"@stripe/stripe-js": "^10.0.0",
"compression": "^1.8.2",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.0",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.22",
"@types/node": "^24.19.1",
"jsdom": "^30.0.0",
"vitest": "^5.0.0"
},
"description": "Sito pubblico ITI TECHNOLOGY (Angular SSR)",
"type": "module"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ITI TECHNOLOGY">
<circle cx="44" cy="18" r="10" fill="#f2a900"/>
<path d="M28 6C28 6 10 28 10 40a18 18 0 0 0 36 0C46 28 28 6 28 6z" fill="#0b5c7a"/>
<path d="M20 42a8 8 0 0 0 8 8" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0e7fa6"/><stop offset="1" stop-color="#0e7fa6" stop-opacity=".7"/></linearGradient></defs><rect width="1200" height="800" fill="#e6f2f6"/><circle cx="600" cy="440" r="330" fill="url(#g)"/><path d="M600 230C600 230 470 400 470 490a130 130 0 0 0 260 0C730 400 600 230 600 230z" fill="#fff" opacity=".95"/><path d="M540 500a60 60 0 0 0 60 60" fill="none" stroke="#0e7fa6" stroke-width="18" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 594 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#c2410c"/><stop offset="1" stop-color="#c2410c" stop-opacity=".7"/></linearGradient></defs><rect width="1200" height="800" fill="#fdebe2"/><circle cx="600" cy="440" r="330" fill="url(#g)"/><path d="M420 600V420l180-140 180 140v180z" fill="#fff" opacity=".95"/><rect x="560" y="490" width="80" height="110" fill="#c2410c"/><rect x="800" y="480" width="120" height="120" rx="12" fill="#fff" opacity=".9"/><circle cx="860" cy="540" r="38" fill="none" stroke="#c2410c" stroke-width="10"/></svg>

After

Width:  |  Height:  |  Size: 666 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#c98a00"/><stop offset="1" stop-color="#c98a00" stop-opacity=".7"/></linearGradient></defs><rect width="1200" height="800" fill="#fff6dc"/><circle cx="600" cy="440" r="330" fill="url(#g)"/><circle cx="760" cy="250" r="80" fill="#fff" opacity=".95"/><g fill="#fff" opacity=".9"><path d="M380 600l80-200h300l-80 200z"/></g><g stroke="#c98a00" stroke-width="8"><path d="M480 400l-60 150M560 400l-60 200M640 400l-60 200M440 500h280M410 560h260"/></g></svg>

After

Width:  |  Height:  |  Size: 628 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#475569"/><stop offset="1" stop-color="#475569" stop-opacity=".7"/></linearGradient></defs><rect width="1200" height="800" fill="#eef1f5"/><circle cx="600" cy="440" r="330" fill="url(#g)"/><path d="M360 620V420l120 70v-70l120 70v-70l120 70V320h80v300z" fill="#fff" opacity=".95"/><g fill="#475569"><rect x="420" y="530" width="50" height="40"/><rect x="520" y="530" width="50" height="40"/><rect x="620" y="530" width="50" height="40"/></g></svg>

After

Width:  |  Height:  |  Size: 622 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f7a4d"/><stop offset="1" stop-color="#1f7a4d" stop-opacity=".7"/></linearGradient></defs><rect width="1200" height="800" fill="#e8f5ee"/><circle cx="600" cy="440" r="330" fill="url(#g)"/><path d="M380 560c0-60 40-90 100-100l60-70h170l70 70c60 10 100 40 100 100v40H380z" fill="#fff" opacity=".95"/><circle cx="480" cy="600" r="40" fill="#1f7a4d" stroke="#fff" stroke-width="14"/><circle cx="760" cy="600" r="40" fill="#1f7a4d" stroke="#fff" stroke-width="14"/><path d="M640 300l-40 70h50l-40 70" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 771 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><rect width="1200" height="800" fill="#e6f2f6"/><rect x="420" y="160" width="160" height="440" rx="70" fill="#0e7fa6"/><rect x="620" y="240" width="160" height="360" rx="70" fill="#0b5c7a"/><path d="M500 300c0 0-40 60-40 90a40 40 0 0 0 80 0c0-30-40-90-40-90z" fill="#fff"/><text x="600" y="700" font-family="system-ui,sans-serif" font-size="44" font-weight="700" fill="#4a5a66" text-anchor="middle">Area trattamento acqua (segnaposto)</text></svg>

After

Width:  |  Height:  |  Size: 537 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><rect width="1200" height="800" fill="#fff6dc"/><g fill="#0b5c7a"><polygon points="300,520 420,260 720,260 600,520"/></g><g stroke="#e6f2f6" stroke-width="10"><line x1="370" y1="390" x2="670" y2="390"/><line x1="460" y1="260" x2="400" y2="520"/><line x1="560" y1="260" x2="500" y2="520"/><line x1="660" y1="260" x2="600" y2="520"/></g><rect x="780" y="300" width="160" height="220" rx="16" fill="#1f7a4d"/><circle cx="860" cy="200" r="60" fill="#f2a900"/><text x="600" y="680" font-family="system-ui,sans-serif" font-size="44" font-weight="700" fill="#4a5a66" text-anchor="middle">Area energia e comfort (segnaposto)</text></svg>

After

Width:  |  Height:  |  Size: 719 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800"><rect width="1200" height="800" fill="#e6f2f6"/><rect y="560" width="1200" height="240" fill="#d9e1e6"/><rect x="140" y="250" width="260" height="310" rx="16" fill="#0b5c7a"/><rect x="170" y="290" width="200" height="120" rx="8" fill="#e6f2f6"/><rect x="470" y="200" width="260" height="360" rx="16" fill="#1f7a4d"/><rect x="500" y="240" width="200" height="60" rx="8" fill="#e8f5ee"/><rect x="800" y="300" width="260" height="260" rx="16" fill="#f2a900"/><circle cx="930" cy="430" r="70" fill="#fff6dc"/><text x="600" y="700" font-family="system-ui,sans-serif" font-size="44" font-weight="700" fill="#4a5a66" text-anchor="middle">Foto showroom (segnaposto)</text></svg>

After

Width:  |  Height:  |  Size: 760 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ITI TECHNOLOGY">
<circle cx="44" cy="18" r="10" fill="#f2a900"/>
<path d="M28 6C28 6 10 28 10 40a18 18 0 0 0 36 0C46 28 28 6 28 6z" fill="#0b5c7a"/>
<path d="M20 42a8 8 0 0 0 8 8" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+85
View File
@@ -0,0 +1,85 @@
// Genera l'indice dei file di contenuto in src/content/generated/ (gli import li scrive lui, non a mano).
// Uso: node scripts/content-index.mjs → (ri)scrive l'indice
// node scripts/content-index.mjs --check → exit 1 se l'indice non è aggiornato (usato dai test)
// Parte da solo prima di dev, build, test e typecheck (script "pre*" in package.json).
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'content')
const outDir = join(root, 'generated')
const check = process.argv.includes('--check')
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
/** File .ts di una cartella (slug = nome senza estensione), ordinati. */
function slugsIn(dir) {
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith('.ts') && !f.endsWith('.spec.ts'))
.map((f) => f.slice(0, -3))
.sort()
.map((slug) => {
if (!SLUG.test(slug)) throw new Error(`Nome file non valido: ${join(dir, slug)}.ts (solo a-z, 0-9 e "-")`)
return slug
})
}
const ident = (...parts) => parts.join('_').replace(/-/g, '_')
const areas = slugsIn(join(root, 'areas'))
const solutionsDir = join(root, 'solutions')
const solutionAreas = existsSync(solutionsDir)
? readdirSync(solutionsDir).filter((d) => statSync(join(solutionsDir, d)).isDirectory()).sort()
: []
const solutions = solutionAreas.flatMap((area) => {
if (!areas.includes(area)) throw new Error(`src/content/solutions/${area}/: non esiste la macro-area src/content/areas/${area}.ts`)
return slugsIn(join(solutionsDir, area)).map((slug) => ({ area, slug }))
})
const companyPages = slugsIn(join(root, 'company'))
const header = '// File generato da scripts/content-index.mjs: non modificare a mano.\n'
const files = {
'paths.ts':
header +
'// Solo slug (dai nomi dei file), senza importare i contenuti: per le route e la sitemap.\n\n' +
`export const AREA_SLUGS: readonly string[] = ${JSON.stringify(areas)}\n\n` +
`export const SOLUTION_PATHS: readonly { area: string; slug: string }[] = ${JSON.stringify(solutions)}\n\n` +
`export const COMPANY_PAGE_SLUGS: readonly string[] = ${JSON.stringify(companyPages)}\n`,
'areas.ts':
header +
"import type { AreaContent } from '../types'\n" +
areas.map((s) => `import ${ident('area', s)} from '../areas/${s}'\n`).join('') +
`\nexport const AREA_FILES: readonly AreaContent[] = [${areas.map((s) => ident('area', s)).join(', ')}]\n`,
'solutions.ts':
header +
"import type { SolutionContent } from '../types'\n" +
solutions.map(({ area, slug }) => `import ${ident('solution', area, slug)} from '../solutions/${area}/${slug}'\n`).join('') +
`\nexport const SOLUTION_FILES: readonly SolutionContent[] = [\n${solutions
.map(({ area, slug }) => ` ${ident('solution', area, slug)},\n`)
.join('')}]\n`,
'company.ts':
header +
"import type { CompanyPageContent } from '../types'\n" +
companyPages.map((s) => `import ${ident('company', s)} from '../company/${s}'\n`).join('') +
`\nexport const COMPANY_PAGE_FILES: readonly CompanyPageContent[] = [${companyPages.map((s) => ident('company', s)).join(', ')}]\n`,
}
let stale = []
if (!check) mkdirSync(outDir, { recursive: true })
for (const [name, content] of Object.entries(files)) {
const path = join(outDir, name)
const current = existsSync(path) ? readFileSync(path, 'utf8') : null
if (current === content) continue
if (check) stale.push(name)
else writeFileSync(path, content)
}
if (check && stale.length) {
console.error(`Indice dei contenuti non aggiornato (${stale.join(', ')}): esegui "npm run content -w apps/public"`)
process.exit(1)
}
console.log(
`Contenuti: ${areas.length} aree, ${solutions.length} soluzioni, ${companyPages.length} pagine azienda${check ? ' (indice aggiornato)' : ''}`,
)
+17
View File
@@ -0,0 +1,17 @@
// Copia in public/vendor/ gli asset statici di librerie npm caricati a runtime (oggi: CSS e immagini di Leaflet,
// usati dalla mappa dei contatti). public/vendor/ è generata e ignorata da git. Parte in postinstall, predev e prebuild.
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const require = createRequire(import.meta.url)
const leafletDist = dirname(require.resolve('leaflet/dist/leaflet.css'))
const target = join(root, 'public', 'vendor', 'leaflet')
mkdirSync(join(target, 'images'), { recursive: true })
copyFileSync(join(leafletDist, 'leaflet.css'), join(target, 'leaflet.css'))
const images = readdirSync(join(leafletDist, 'images')).filter((f) => f.endsWith('.png'))
for (const f of images) copyFileSync(join(leafletDist, 'images', f), join(target, 'images', f))
console.log(`Asset vendor: leaflet.css + ${images.length} immagini in public/vendor/leaflet`)
+33
View File
@@ -0,0 +1,33 @@
import { HTTP_TRANSFER_CACHE_ORIGIN_MAP } from '@angular/common/http'
import { ApplicationConfig, inject, mergeApplicationConfig, provideEnvironmentInitializer, TransferState } from '@angular/core'
import { provideServerRendering, withRoutes } from '@angular/ssr'
import { loadServerConfig, publicSiteConfig } from '../server/runtime-config'
import { appConfig } from './app.config'
import { serverRoutes } from './app.routes.server'
import { API_BASE_URL, SITE_CONFIG, SITE_CONFIG_STATE } from './core/app-config'
const serverConfig = loadServerConfig()
const siteConfig = publicSiteConfig(serverConfig)
/**
* Provider solo server: configurazione dalle variabili d'ambiente, consegnata al browser via TransferState;
* chiamate all'API sull'indirizzo interno, con la mappa di origine per riusare la transfer cache nel browser.
*/
const serverOnly: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes)),
{ provide: SITE_CONFIG, useValue: siteConfig },
{ provide: API_BASE_URL, useValue: serverConfig.apiInternalUrl },
...(serverConfig.apiInternalUrl !== serverConfig.apiUrl
? [
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: { [new URL(serverConfig.apiInternalUrl).origin]: new URL(serverConfig.apiUrl).origin },
},
]
: []),
provideEnvironmentInitializer(() => inject(TransferState).set(SITE_CONFIG_STATE, siteConfig)),
],
}
export const config = mergeApplicationConfig(appConfig, serverOnly)
+27
View File
@@ -0,0 +1,27 @@
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core'
import { registerLocaleData } from '@angular/common'
import localeIt from '@angular/common/locales/it'
import { provideClientHydration, withEventReplay, withHttpTransferCacheOptions } from '@angular/platform-browser'
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'
import { routes } from './app.routes'
import { authInterceptor } from './core/auth.interceptor'
import { forwardedForInterceptor } from './core/forwarded-for.interceptor'
registerLocaleData(localeIt)
/** Configurazione comune a browser e server (zoneless è il default da Angular 21). */
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
{ provide: LOCALE_ID, useValue: 'it-IT' },
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
),
provideHttpClient(withFetch(), withInterceptors([forwardedForInterceptor, authInterceptor])),
// Hydration + transfer cache: le GET fatte sul server non vengono ripetute dal browser
provideClientHydration(withEventReplay(), withHttpTransferCacheOptions({ includePostRequests: false })),
],
}
+16
View File
@@ -0,0 +1,16 @@
import { RenderMode, ServerRoute } from '@angular/ssr'
/**
* Tutte le pagine sono renderizzate sul server a ogni richiesta (RenderMode.Server), niente prerender:
* la configurazione (dominio, URL dell'API) arriva dalle variabili d'ambiente a runtime, mentre il prerender
* la congelerebbe al momento della build. Il caching si farà con gli header HTTP (prompt 33).
*/
export const serverRoutes: ServerRoute[] = [
// Redirect definitivi (301 invece del 302 di default): URL duplicati da non indicizzare
{ path: 'azienda/chi-siamo', renderMode: RenderMode.Server, status: 301 },
{ path: 'soluzioni', renderMode: RenderMode.Server, status: 301 },
{
path: '**',
renderMode: RenderMode.Server,
},
]
+50
View File
@@ -0,0 +1,50 @@
import type { CanMatchFn, Routes, UrlSegment } from '@angular/router'
import { isAreaSlug, isCompanyPageSlug, isSolutionPath } from './core/site'
/** Lo slug del segmento `index` esiste tra i file di contenuto? Altrimenti la route non combacia → 404. */
const segment = (segments: UrlSegment[], index: number) => segments[index]?.path ?? ''
const isArea: CanMatchFn = (_route, segments) => isAreaSlug(segment(segments, 1))
const isSolution: CanMatchFn = (_route, segments) => isSolutionPath(segment(segments, 1), segment(segments, 2))
const isCompanyPage: CanMatchFn = (_route, segments) => isCompanyPageSlug(segment(segments, 1))
const isOrderNumber: CanMatchFn = (_route, segments) => /^[1-9]\d{0,9}$/.test(segment(segments, 1))
/**
* Mappa delle route (tutte lazy). I parametri arrivano ai componenti come input (withComponentInputBinding).
* Il render mode di ogni route è in app.routes.server.ts.
*/
export const routes: Routes = [
{ path: '', pathMatch: 'full', loadComponent: () => import('./pages/home') },
{ path: 'azienda', pathMatch: 'full', loadComponent: () => import('./pages/company') },
// /azienda mostra già "Chi siamo": niente URL duplicato
{ path: 'azienda/chi-siamo', pathMatch: 'full', redirectTo: '/azienda' },
{ path: 'azienda/:pagina', canMatch: [isCompanyPage], loadComponent: () => import('./pages/company') },
{ path: 'soluzioni', pathMatch: 'full', redirectTo: '/' },
{ path: 'soluzioni/:area', canMatch: [isArea], loadComponent: () => import('./pages/solution-area') },
{ path: 'soluzioni/:area/:soluzione', canMatch: [isSolution], loadComponent: () => import('./pages/solution') },
{ path: 'shop', pathMatch: 'full', loadComponent: () => import('./pages/shop') },
// Categoria o prodotto inesistenti → 404 deciso dalla pagina dopo la risposta dell'API
{ path: 'shop/categoria/:categoria', loadComponent: () => import('./pages/shop') },
{ path: 'shop/:handle', loadComponent: () => import('./pages/product') },
{ path: 'carrello', loadComponent: () => import('./pages/cart') },
{ path: 'checkout', loadComponent: () => import('./pages/checkout') },
{ path: 'ordine/:numero/confermato', canMatch: [isOrderNumber], loadComponent: () => import('./pages/order-confirmed') },
{ path: 'blog', pathMatch: 'full', loadComponent: () => import('./pages/blog') },
// Categoria inesistente, articolo inesistente → 404 deciso dalla pagina dopo la risposta dell'API
{ path: 'blog/categoria/:categoria', loadComponent: () => import('./pages/blog') },
{ path: 'blog/:slug', loadComponent: () => import('./pages/blog-post') },
{ path: 'configuratore', pathMatch: 'full', loadComponent: () => import('./pages/configurator') },
{ path: 'configuratore/grazie', loadComponent: () => import('./pages/configurator-thanks') },
{ path: 'realizzazioni', loadComponent: () => import('./pages/projects') },
{ path: 'showroom', loadComponent: () => import('./pages/showroom') },
{ path: 'contatti', loadComponent: () => import('./pages/contact') },
{ path: 'privacy', loadComponent: () => import('./pages/privacy') },
{ path: 'cookie', loadComponent: () => import('./pages/cookie') },
{ path: '**', loadComponent: () => import('./pages/not-found') },
]
+69
View File
@@ -0,0 +1,69 @@
import { afterNextRender, ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { CartStore } from './core/cart.store'
import { ConsentService } from './core/consent.service'
import { SessionService } from './core/session.service'
import { Footer } from './layout/footer'
import { CookieBanner } from './layout/cookie-banner'
import { Header } from './layout/header'
/** Shell del sito: link "salta al contenuto", header, pagina corrente, footer, banner cookie. */
@Component({
selector: 'app-root',
imports: [RouterOutlet, Header, Footer, CookieBanner],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a class="skip-link" href="#contenuto">Vai al contenuto</a>
<app-header />
<main id="contenuto" tabindex="-1">
<router-outlet />
</main>
<app-footer />
<!-- Banner cookie: il suo codice si scarica solo quando va mostrato (prima visita o preferenze riaperte) -->
@if (consent.panel() !== 'closed') {
@defer {
<app-cookie-banner />
}
}
`,
styles: `
:host {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
outline: none;
}
.skip-link {
position: absolute;
left: var(--space-4);
top: -100px;
z-index: 100;
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: var(--color-on-primary);
border-radius: var(--radius-md);
&:focus {
top: var(--space-2);
}
}
`,
})
export class App {
protected readonly consent = inject(ConsentService)
constructor() {
// Solo nel browser, dopo l'idratazione: consenso cookie, sessione (cookie di refresh condiviso con l'area
// riservata), poi carrello
const session = inject(SessionService)
const cart = inject(CartStore)
afterNextRender(() => {
this.consent.init()
void session.ready()
cart.load()
})
}
}
@@ -0,0 +1,56 @@
import { provideHttpClient } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService } from './api.service'
import { API_BASE_URL } from './app-config'
describe('ApiService', () => {
let api: ApiService
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: API_BASE_URL, useValue: 'http://api.test' }],
})
api = TestBed.inject(ApiService)
http = TestBed.inject(HttpTestingController)
})
afterEach(() => http.verify())
it('compone base URL e query, senza parametri vuoti', async () => {
const result = firstValueFrom(api.get<{ ok: boolean }>('/blog/posts', { query: { q: 'pompa', limit: 10, category: undefined, empty: '' } }))
const req = http.expectOne((r) => r.url === 'http://api.test/blog/posts')
expect(req.request.params.toString()).toBe('q=pompa&limit=10')
expect(req.request.withCredentials).toBe(false)
req.flush({ ok: true })
expect(await result).toEqual({ ok: true })
})
it('withCredentials per le route con cookie', () => {
void firstValueFrom(api.post('/shop/cart', {}, { withCredentials: true }))
const req = http.expectOne('http://api.test/shop/cart')
expect(req.request.method).toBe('POST')
expect(req.request.withCredentials).toBe(true)
req.flush({})
})
it("errori nel formato dell'API → ApiError con code, message e details", async () => {
const result = firstValueFrom(api.post('/contact', {}))
http
.expectOne('http://api.test/contact')
.flush(
{ error: { code: 'VALIDATION_ERROR', message: 'Dati non validi', details: [{ path: 'body.email', message: 'Email non valida' }] } },
{ status: 400, statusText: 'Bad Request' },
)
const err = await result.catch((e: unknown) => e)
expect(err).toBeInstanceOf(ApiError)
expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', message: 'Dati non validi', details: [{ path: 'body.email' }] })
})
it('errore di rete → NETWORK_ERROR', async () => {
const result = firstValueFrom(api.get('/health'))
http.expectOne('http://api.test/health').error(new ProgressEvent('error'), { status: 0 })
await expect(result).rejects.toMatchObject({ code: 'NETWORK_ERROR', status: 0 })
})
})
+103
View File
@@ -0,0 +1,103 @@
import { HttpClient, HttpContext, HttpContextToken, HttpErrorResponse, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { catchError, Observable, throwError } from 'rxjs'
import { API_BASE_URL } from './app-config'
/** Errore dell'API nel formato unico `{ error: { code, message, details? } }` di apps/api. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details: { path: string; message: string }[] = [],
) {
super(message)
this.name = 'ApiError'
}
}
type QueryValue = string | number | boolean | null | undefined
export type Query = Record<string, QueryValue>
export interface RequestOptions {
query?: Query
/** Invia i cookie (carrello, refresh): serve per le route con stato utente. */
withCredentials?: boolean
/**
* Invia l'access token se l'utente ha una sessione (authInterceptor): prezzi riservati e carrello dell'account.
* Solo per le route con autenticazione facoltativa (`/shop/*`).
*/
auth?: boolean
}
/** Richiesta da autenticare con l'access token della sessione, se presente (vedi auth.interceptor.ts). */
export const WITH_AUTH = new HttpContextToken<boolean>(() => false)
function httpOptions(options: RequestOptions) {
return {
params: toParams(options.query),
withCredentials: options.withCredentials ?? false,
context: new HttpContext().set(WITH_AUTH, options.auth ?? false),
}
}
function toParams(query: Query | undefined): HttpParams {
let params = new HttpParams()
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value))
}
return params
}
export function toApiError(err: unknown): ApiError {
if (err instanceof HttpErrorResponse) {
const body = err.error as { error?: { code?: string; message?: string; details?: { path: string; message: string }[] } } | null
if (body?.error?.code) {
return new ApiError(err.status, body.error.code, body.error.message ?? 'Errore', body.error.details ?? [])
}
// status 0 = rete, CORS o API non raggiungibile
return err.status === 0
? new ApiError(0, 'NETWORK_ERROR', 'Servizio non raggiungibile: riprova tra poco')
: new ApiError(err.status, 'HTTP_ERROR', 'Si è verificato un errore: riprova tra poco')
}
return new ApiError(0, 'UNKNOWN_ERROR', 'Si è verificato un errore imprevisto')
}
/**
* Client dell'API Fastify (unico backend del frontend: Medusa non viene mai chiamato direttamente).
* Le GET fatte durante il rendering sul server vengono riutilizzate dal browser all'idratazione
* (HttpTransferCache di provideClientHydration): nessuna chiamata ripetuta.
*/
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient)
private readonly baseUrl = inject(API_BASE_URL)
url(path: string): string {
return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`
}
get<T>(path: string, options: RequestOptions = {}): Observable<T> {
return this.http
.get<T>(this.url(path), httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
post<T>(path: string, body: unknown, options: RequestOptions = {}): Observable<T> {
return this.http
.post<T>(this.url(path), body, httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
patch<T>(path: string, body: unknown, options: RequestOptions = {}): Observable<T> {
return this.http
.patch<T>(this.url(path), body, httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
delete<T>(path: string, options: RequestOptions = {}): Observable<T> {
return this.http
.delete<T>(this.url(path), httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
}
+33
View File
@@ -0,0 +1,33 @@
import { InjectionToken, inject, makeStateKey, TransferState } from '@angular/core'
import { environment } from '../../environments/environment'
/** Configurazione runtime del sito, uguale sul server e nel browser. */
export interface SiteConfig {
siteUrl: string
apiUrl: string
privateAppUrl: string
indexable: boolean
/** Chiave pubblicabile Stripe (pk_test_… / pk_live_…) per il Payment Element; '' = pagamento non configurato. */
stripePublishableKey: string
}
/** Chiave TransferState con cui il server consegna la configurazione al browser. */
export const SITE_CONFIG_STATE = makeStateKey<SiteConfig>('iti-site-config')
/**
* Configurazione del sito. Nel browser viene dal TransferState scritto dal server (src/app/app.config.server.ts);
* se manca (es. test) valgono i default di environment.ts.
*/
export const SITE_CONFIG = new InjectionToken<SiteConfig>('SITE_CONFIG', {
providedIn: 'root',
factory: () => inject(TransferState).get(SITE_CONFIG_STATE, environment),
})
/**
* Base URL dell'API usata da HttpClient. Di default è `SITE_CONFIG.apiUrl`; sul server può puntare a un
* indirizzo interno (API_INTERNAL_URL, es. http://api:3000 in Docker).
*/
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
providedIn: 'root',
factory: () => inject(SITE_CONFIG).apiUrl,
})
@@ -0,0 +1,37 @@
import { HttpErrorResponse, type HttpInterceptorFn, type HttpRequest } from '@angular/common/http'
import { inject } from '@angular/core'
import { catchError, from, switchMap, throwError } from 'rxjs'
import { WITH_AUTH } from './api.service'
import { SessionService } from './session.service'
/**
* Aggiunge `Authorization: Bearer …` alle richieste marcate con `auth: true` (ApiService), se c'è una sessione.
*
* - Token valido: aggiunto subito, senza attese. Le richieste fatte durante l'idratazione partono quindi senza
* ritardi e la transfer cache dell'SSR continua a servirle (chi ha bisogno della sessione, come il carrello,
* attende `SessionService.ready()` prima di chiamare).
* - Token scaduto con sessione attiva: refresh, poi la richiesta.
* - Risposta 401 a una richiesta con token: un refresh e un solo nuovo tentativo; se la sessione non c'è più,
* il nuovo tentativo parte da anonimo.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.context.get(WITH_AUTH)) return next(req)
const session = inject(SessionService)
const withToken = (request: HttpRequest<unknown>, token: string | null) =>
token ? request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : request
const send = (token: string | null) =>
next(withToken(req, token)).pipe(
catchError((err: unknown) => {
if (token && err instanceof HttpErrorResponse && err.status === 401) {
return from(session.refresh()).pipe(switchMap((ok) => next(withToken(req, ok ? session.currentToken() : null))))
}
return throwError(() => err)
}),
)
const token = session.currentToken()
if (token || session.status() !== 'authenticated') return send(token)
return from(session.refresh()).pipe(switchMap((ok) => send(ok ? session.currentToken() : null)))
}
+14
View File
@@ -0,0 +1,14 @@
/**
* URL del blog: elenco `/blog`, per categoria `/blog/categoria/<slug>` (pagine indicizzabili, in sitemap),
* ricerca e paginazione come in core/listing.ts.
*/
/** Articoli per pagina dell'elenco. */
export const BLOG_PAGE_SIZE = 9
/** Lunghezza massima della ricerca (come la validazione dell'API). */
export const BLOG_QUERY_MAX = 200
export function blogListPath(category?: string | null): string {
return category ? `/blog/categoria/${encodeURIComponent(category)}` : '/blog'
}
+146
View File
@@ -0,0 +1,146 @@
import type { ShopCartDto } from '@iti/shared'
import { isPlatformBrowser } from '@angular/common'
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'
import { firstValueFrom, type Observable } from 'rxjs'
import { ApiError, ApiService } from './api.service'
import { SessionService } from './session.service'
import { MAX_QUANTITY } from './shop'
export type CartState = 'idle' | 'loading' | 'ready' | 'error'
/**
* Carrello (signals). Vive solo nel browser: il server non lo carica mai (il cookie `cart_id` è dell'utente
* e la pagina renderizzata deve essere uguale per tutti).
*
* - Ogni chiamata manda i cookie (`cart_id`, httpOnly e firmato, gestito dall'API) e, se c'è una sessione,
* l'access token: per un utente loggato l'API usa il carrello dell'account (prezzi riservati compresi).
* - Le operazioni sono messe in coda: due clic ravvicinati su "+" non possono arrivare in ordine inverso.
* - Ogni risposta contiene il carrello completo: totali e badge si aggiornano appena l'API risponde.
*/
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly api = inject(ApiService)
private readonly session = inject(SessionService)
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID))
private readonly _cart = signal<ShopCartDto | null>(null)
private readonly _state = signal<CartState>('idle')
private readonly _pending = signal(0)
private readonly _error = signal<string | null>(null)
/** Carrello attuale; null = nessun carrello (vuoto). */
readonly cart = this._cart.asReadonly()
readonly state = this._state.asReadonly()
/** Messaggio dell'ultima operazione non riuscita (null dopo un'operazione riuscita). */
readonly error = this._error.asReadonly()
readonly busy = computed(() => this._pending() > 0)
readonly itemCount = computed(() => this._cart()?.itemCount ?? 0)
readonly isEmpty = computed(() => (this._cart()?.items.length ?? 0) === 0)
private queue: Promise<unknown> = Promise.resolve()
/** Primo caricamento (una volta sola; nel browser). */
load(): void {
if (!this.isBrowser || this._state() !== 'idle') return
this._state.set('loading')
void this.run(() => this.api.get<ShopCartDto>('/shop/cart', this.options), true)
}
/** Ricarica dall'API (es. ritorno sulla scheda dopo modifiche fatte altrove). */
reload(): Promise<boolean> {
return this.run(() => this.api.get<ShopCartDto>('/shop/cart', this.options), true)
}
add(variantId: string, quantity = 1): Promise<boolean> {
return this.run(() => this.api.post<ShopCartDto>('/shop/cart/items', { variantId, quantity }, this.options))
}
setQuantity(itemId: string, quantity: number): Promise<boolean> {
return this.run(() =>
this.api.patch<ShopCartDto>(`/shop/cart/items/${encodeURIComponent(itemId)}`, { quantity }, this.options),
)
}
/**
* Cambia la quantità di `delta` (es. pulsanti − e +). La nuova quantità si calcola quando tocca a questa
* operazione nella coda, sul carrello già aggiornato: due clic rapidi su "+" danno +2, non +1 due volte.
*/
adjust(itemId: string, delta: number): Promise<boolean> {
return this.run(() => {
const item = this._cart()?.items.find((i) => i.id === itemId)
if (!item) return null
const quantity = Math.min(MAX_QUANTITY, Math.max(1, item.quantity + delta))
if (quantity === item.quantity) return null
return this.api.patch<ShopCartDto>(`/shop/cart/items/${encodeURIComponent(itemId)}`, { quantity }, this.options)
})
}
remove(itemId: string): Promise<boolean> {
return this.run(() => this.api.delete<ShopCartDto>(`/shop/cart/items/${encodeURIComponent(itemId)}`, this.options))
}
clearError(): void {
this._error.set(null)
}
/**
* Sostituisce il carrello con quello restituito da una chiamata fatta fuori dallo store (checkout: indirizzo,
* spedizione). Attende le operazioni in coda, così una risposta più vecchia non sovrascrive quella nuova.
*/
async replace(cart: ShopCartDto): Promise<void> {
await this.queue
this._cart.set(cart)
this._state.set('ready')
}
/** Carrello chiuso (ordine creato): l'API ha già cancellato il cookie, il prossimo carrello sarà nuovo. */
async clear(): Promise<void> {
await this.queue
this._cart.set(null)
this._state.set('ready')
this._error.set(null)
}
private readonly options = { withCredentials: true, auth: true }
/**
* Esegue una chiamata dopo quelle già in coda e dopo che la sessione è nota (per usare il carrello giusto).
* `call` viene invocata solo allora, quindi può leggere il carrello aggiornato dalle operazioni precedenti.
* `CART_NOT_FOUND` = nessun carrello: per le letture è il carrello vuoto, non un errore.
*/
private run(call: () => Observable<ShopCartDto> | null, read = false): Promise<boolean> {
if (!this.isBrowser) return Promise.resolve(false)
this._pending.update((n) => n + 1)
const task = this.queue.then(async () => {
try {
await this.session.ready()
// null: niente da fare (es. quantità già al limite)
const request = call()
if (!request) return true
this._cart.set(await firstValueFrom(request))
this._state.set('ready')
this._error.set(null)
return true
} catch (err) {
const error = err instanceof ApiError ? err : new ApiError(0, 'UNKNOWN_ERROR', 'Si è verificato un errore imprevisto')
if (error.code === 'CART_NOT_FOUND') {
this._cart.set(null)
this._state.set('ready')
if (read) {
this._error.set(null)
return true
}
this._error.set('Il carrello non è più disponibile: aggiungi di nuovo i prodotti.')
return false
}
if (this._state() === 'loading') this._state.set('error')
this._error.set(error.message)
return false
} finally {
this._pending.update((n) => n - 1)
}
})
this.queue = task
return task
}
}
+195
View File
@@ -0,0 +1,195 @@
import type { ShopCartDto } from '@iti/shared'
import { ApiError } from './api.service'
import {
addressRequestBody,
applyServerErrors,
createAddressStepForm,
fillAddressStepForm,
firstOpenStep,
syncOptionalGroups,
} from './checkout'
import { formatEuro } from './format'
import { readLastOrder, saveLastOrder } from './order-confirmation'
import { fieldErrorMessage } from '../shared/form-field'
const address = {
firstName: 'Mario',
lastName: 'Rossi',
company: null,
address1: 'Via Roma 1',
address2: null,
city: 'Milano',
postalCode: '20100',
province: 'MI',
countryCode: 'it',
phone: '3330000000',
}
function cart(overrides: Partial<ShopCartDto> = {}): ShopCartDto {
return {
email: null,
items: [],
itemCount: 0,
shippingAddress: null,
billingAddress: null,
invoice: null,
shippingMethod: null,
totals: { items: 0, shipping: 0, discount: 0, tax: 0, total: 0 },
readyForPayment: false,
...overrides,
}
}
function filledForm() {
const form = createAddressStepForm()
form.controls.shipping.setValue({
firstName: ' Mario ',
lastName: 'Rossi',
company: '',
address1: 'Via Roma 1',
address2: '',
postalCode: '20100',
city: 'Milano',
province: 'mi',
phone: '333 0000000',
})
form.controls.acceptPrivacy.setValue(true)
syncOptionalGroups(form)
return form
}
describe('checkout: form indirizzo', () => {
it('fatturazione e fattura validate solo se richieste', () => {
const form = filledForm()
expect(form.valid).toBe(true)
form.controls.wantsInvoice.setValue(true)
syncOptionalGroups(form)
expect(form.valid).toBe(false)
form.controls.invoice.patchValue({ companyName: 'Rossi Srl', vatNumber: '01234567890' })
expect(form.controls.invoice.controls.vatNumber.errors).toEqual({ vat: true })
form.controls.invoice.patchValue({ vatNumber: 'IT 00743110157', sdiCode: 'abc12' })
expect(form.controls.invoice.controls.sdiCode.errors).toEqual({ pattern: true })
form.controls.invoice.patchValue({ sdiCode: 'abc1234' })
expect(form.valid).toBe(true)
form.controls.billingDifferent.setValue(true)
syncOptionalGroups(form)
expect(form.valid).toBe(false) // indirizzo di fatturazione vuoto
})
it('privacy obbligatoria, CAP e provincia validati', () => {
const form = filledForm()
form.controls.acceptPrivacy.setValue(false)
expect(form.valid).toBe(false)
form.controls.acceptPrivacy.setValue(true)
form.controls.shipping.patchValue({ postalCode: '2010', province: 'Milano' })
expect(form.controls.shipping.controls.postalCode.errors).toEqual({ pattern: true })
expect(form.controls.shipping.controls.province.errors).toEqual({ pattern: true })
})
it('corpo della richiesta: valori puliti, fattura normalizzata, email solo per il guest', () => {
const form = filledForm()
form.controls.wantsInvoice.setValue(true)
form.controls.invoice.patchValue({ companyName: ' Rossi Srl ', vatNumber: 'IT 00743110157', sdiCode: 'abc1234' })
const body = addressRequestBody(form, 'guest@example.com')
expect(body).toEqual({
email: 'guest@example.com',
shippingAddress: {
firstName: 'Mario',
lastName: 'Rossi',
company: undefined,
address1: 'Via Roma 1',
address2: undefined,
postalCode: '20100',
city: 'Milano',
province: 'MI',
countryCode: 'it',
phone: '333 0000000',
},
invoice: { companyName: 'Rossi Srl', vatNumber: '00743110157', fiscalCode: undefined, sdiCode: 'ABC1234', pec: undefined },
acceptPrivacy: true,
acceptMarketing: false,
})
form.controls.wantsInvoice.setValue(false)
const account = addressRequestBody(form, null)
expect('email' in account).toBe(false)
expect('billingAddress' in account).toBe(false)
expect(account.invoice).toBeNull()
})
it('precompilato dal carrello: fatturazione diversa e fattura riconosciute', () => {
const form = createAddressStepForm()
fillAddressStepForm(
form,
cart({
email: 'g@example.com',
shippingAddress: address,
billingAddress: { ...address, address1: 'Via Verdi 2' },
invoice: { companyName: 'Rossi Srl', vatNumber: '00743110157', fiscalCode: null, sdiCode: null, pec: 'a@pec.it' },
}),
)
expect(form.controls.billingDifferent.value).toBe(true)
expect(form.controls.billing.controls.address1.value).toBe('Via Verdi 2')
expect(form.controls.wantsInvoice.value).toBe(true)
expect(form.controls.invoice.controls.pec.value).toBe('a@pec.it')
expect(form.controls.acceptPrivacy.value).toBe(true)
expect(form.valid).toBe(true)
const same = createAddressStepForm()
fillAddressStepForm(same, cart({ shippingAddress: address, billingAddress: address }))
expect(same.controls.billingDifferent.value).toBe(false)
expect(same.controls.billing.disabled).toBe(true)
})
it('errori di validazione dell\'API riportati sui campi', () => {
const form = filledForm()
const error = new ApiError(400, 'VALIDATION_ERROR', 'Dati non validi', [
{ path: 'body.shippingAddress.postalCode', message: 'CAP non valido' },
{ path: 'body.invoice.vatNumber', message: 'Partita IVA non valida' },
{ path: 'body.altro', message: 'Errore generico' },
])
form.controls.wantsInvoice.setValue(true)
syncOptionalGroups(form)
const unmatched = applyServerErrors(form, error)
expect(form.controls.shipping.controls.postalCode.errors).toEqual({ server: 'CAP non valido' })
expect(form.controls.invoice.controls.vatNumber.errors).toEqual({ server: 'Partita IVA non valida' })
expect(unmatched).toEqual(['Errore generico'])
expect(fieldErrorMessage({ server: 'CAP non valido' })).toBe('CAP non valido')
expect(fieldErrorMessage({ pattern: true }, { pattern: 'CAP di 5 cifre' })).toBe('CAP di 5 cifre')
})
})
describe('checkout: passi e conferma', () => {
it('primo passo da completare', () => {
expect(firstOpenStep(cart(), false)).toBe('contact')
expect(firstOpenStep(cart(), true)).toBe('address')
expect(firstOpenStep(cart({ email: 'a@b.it', shippingAddress: address }), true)).toBe('shipping')
expect(
firstOpenStep(cart({ email: 'a@b.it', shippingAddress: address, shippingMethod: { optionId: 'so', name: 'S', amount: 1500 } }), true),
).toBe('payment')
})
it('riepilogo dell\'ordine solo per il numero salvato in questa scheda', () => {
const order = {
number: 42,
email: 'g@example.com',
createdAt: '2026-10-03T10:00:00.000Z',
items: [],
totals: { items: 0, shipping: 0, tax: 0, total: 0 },
}
saveLastOrder({ order, guest: true })
expect(readLastOrder(42)).toEqual({ order, guest: true })
expect(readLastOrder(43)).toBeNull()
})
})
describe('formatEuro', () => {
it('interi senza decimali, centesimi sempre a due cifre', () => {
expect(formatEuro(119000)).toBe('1.190 €')
expect(formatEuro(99270)).toBe('992,70 €')
expect(formatEuro(5)).toBe('0,05 €')
})
})
+207
View File
@@ -0,0 +1,207 @@
import type { ShopAddressDto, ShopCartDto } from '@iti/shared'
import { FISCAL_CODE_PATTERN, isValidItalianVat, normalizeVatNumber, SDI_CODE_PATTERN } from '@iti/shared'
import { type AbstractControl, FormControl, FormGroup, type ValidatorFn, Validators } from '@angular/forms'
import type { ApiError } from './api.service'
/** Passi del checkout, nell'ordine. */
export const CHECKOUT_STEPS = ['contact', 'address', 'shipping', 'payment'] as const
export type CheckoutStep = (typeof CHECKOUT_STEPS)[number]
const text = (value = '', ...validators: ValidatorFn[]) =>
new FormControl(value, { nonNullable: true, validators })
const optional = (max: number) => text('', Validators.maxLength(max))
const required = (max: number) => text('', Validators.required, Validators.maxLength(max))
/** Valida il valore senza spazi iniziali e finali (le API fanno trim). */
const trimmedPattern =
(pattern: RegExp, upper = false): ValidatorFn =>
(control: AbstractControl) => {
const raw = String(control.value ?? '').trim()
const value = upper ? raw.toUpperCase() : raw
return value === '' || pattern.test(value) ? null : { pattern: true }
}
const vatValidator: ValidatorFn = (control) => {
const value = String(control.value ?? '').trim()
return value === '' || isValidItalianVat(value) ? null : { vat: true }
}
export const POSTAL_CODE = /^\d{5}$/
export const PROVINCE = /^[A-Z]{2}$/
export const PHONE = /^\+?[0-9 ()-]{6,20}$/
export function createAddressForm(phoneRequired: boolean) {
return new FormGroup({
firstName: required(100),
lastName: required(100),
company: optional(200),
address1: required(200),
address2: optional(200),
postalCode: text('', Validators.required, trimmedPattern(POSTAL_CODE)),
city: required(100),
province: text('', Validators.required, trimmedPattern(PROVINCE, true)),
phone: text('', ...(phoneRequired ? [Validators.required] : []), trimmedPattern(PHONE), Validators.maxLength(30)),
})
}
export type AddressForm = ReturnType<typeof createAddressForm>
export function createInvoiceForm() {
return new FormGroup({
companyName: required(200),
vatNumber: text('', Validators.required, vatValidator),
fiscalCode: text('', trimmedPattern(FISCAL_CODE_PATTERN, true)),
sdiCode: text('', trimmedPattern(SDI_CODE_PATTERN, true)),
pec: text('', Validators.email, Validators.maxLength(254)),
})
}
export type InvoiceForm = ReturnType<typeof createInvoiceForm>
/** Form del passo "Indirizzo": spedizione, fatturazione (se diversa), dati per la fattura, consensi. */
export function createAddressStepForm() {
return new FormGroup({
shipping: createAddressForm(true),
billingDifferent: new FormControl(false, { nonNullable: true }),
billing: createAddressForm(false),
wantsInvoice: new FormControl(false, { nonNullable: true }),
invoice: createInvoiceForm(),
acceptPrivacy: new FormControl(false, { nonNullable: true, validators: [Validators.requiredTrue] }),
acceptMarketing: new FormControl(false, { nonNullable: true }),
})
}
export type AddressStepForm = ReturnType<typeof createAddressStepForm>
/** Fatturazione e fattura sono validate solo se le relative caselle sono spuntate. */
export function syncOptionalGroups(form: AddressStepForm): void {
const toggle = (group: FormGroup, on: boolean) => {
if (on && group.disabled) group.enable({ emitEvent: false })
if (!on && group.enabled) group.disable({ emitEvent: false })
}
toggle(form.controls.billing, form.controls.billingDifferent.value)
toggle(form.controls.invoice, form.controls.wantsInvoice.value)
}
const sameAddress = (a: ShopAddressDto, b: ShopAddressDto) =>
(['firstName', 'lastName', 'company', 'address1', 'address2', 'city', 'postalCode', 'province'] as const).every(
(k) => (a[k] ?? '') === (b[k] ?? ''),
)
function addressValue(a: ShopAddressDto) {
return {
firstName: a.firstName,
lastName: a.lastName,
company: a.company ?? '',
address1: a.address1,
address2: a.address2 ?? '',
postalCode: a.postalCode,
city: a.city,
province: a.province ?? '',
phone: a.phone ?? '',
}
}
/** Precompila il form con i dati già nel carrello (ritorno al checkout, ricarica della pagina). */
export function fillAddressStepForm(form: AddressStepForm, cart: ShopCartDto): void {
if (cart.shippingAddress) {
form.controls.shipping.patchValue(addressValue(cart.shippingAddress))
if (cart.billingAddress && !sameAddress(cart.shippingAddress, cart.billingAddress)) {
form.controls.billingDifferent.setValue(true)
form.controls.billing.patchValue(addressValue(cart.billingAddress))
}
// Il consenso privacy è già stato registrato con l'indirizzo
form.controls.acceptPrivacy.setValue(true)
}
if (cart.invoice) {
form.controls.wantsInvoice.setValue(true)
form.controls.invoice.patchValue({
companyName: cart.invoice.companyName,
vatNumber: cart.invoice.vatNumber,
fiscalCode: cart.invoice.fiscalCode ?? '',
sdiCode: cart.invoice.sdiCode ?? '',
pec: cart.invoice.pec ?? '',
})
}
syncOptionalGroups(form)
}
const blankToUndefined = (v: string) => v.trim() || undefined
function addressBody(value: ReturnType<AddressForm['getRawValue']>) {
return {
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
company: blankToUndefined(value.company),
address1: value.address1.trim(),
address2: blankToUndefined(value.address2),
postalCode: value.postalCode.trim(),
city: value.city.trim(),
province: value.province.trim().toUpperCase(),
countryCode: 'it' as const,
phone: blankToUndefined(value.phone),
}
}
/** Corpo di POST /shop/cart/address. `email` solo per chi non ha fatto l'accesso. */
export function addressRequestBody(form: AddressStepForm, email: string | null) {
const v = form.getRawValue()
const invoice = v.wantsInvoice
? {
companyName: v.invoice.companyName.trim(),
vatNumber: normalizeVatNumber(v.invoice.vatNumber),
fiscalCode: blankToUndefined(v.invoice.fiscalCode.toUpperCase()),
sdiCode: blankToUndefined(v.invoice.sdiCode.toUpperCase()),
pec: blankToUndefined(v.invoice.pec),
}
: null
return {
...(email ? { email } : {}),
shippingAddress: addressBody(v.shipping),
...(v.billingDifferent ? { billingAddress: addressBody(v.billing) } : {}),
invoice,
acceptPrivacy: v.acceptPrivacy,
acceptMarketing: v.acceptMarketing,
}
}
/** Percorsi dei dettagli di validazione dell'API → controlli del form. */
const API_PATHS: Record<string, string> = { shippingAddress: 'shipping', billingAddress: 'billing' }
/**
* Riporta sui campi gli errori di validazione dell'API (`details[].path` = "body.shippingAddress.postalCode").
* Restituisce i messaggi che non corrispondono a nessun campo, da mostrare in cima al form.
*/
export function applyServerErrors(form: FormGroup, error: ApiError): string[] {
const unmatched: string[] = []
for (const detail of error.details) {
const [, ...parts] = detail.path.split('.')
const path = parts.map((p, i) => (i === 0 ? (API_PATHS[p] ?? p) : p)).join('.')
const control = path ? form.get(path) : null
if (control) {
control.setErrors({ server: detail.message })
control.markAsTouched()
} else {
unmatched.push(detail.message)
}
}
return unmatched
}
/** Righe dell'indirizzo per i riepiloghi. */
export function addressLines(a: ShopAddressDto): string[] {
return [
`${a.firstName} ${a.lastName}`,
a.company,
a.address1,
a.address2,
`${a.postalCode} ${a.city}${a.province ? ` (${a.province})` : ''}`,
a.phone ? `Tel. ${a.phone}` : null,
].filter((l): l is string => Boolean(l))
}
/** Primo passo da completare, dato il carrello e se l'email è già nota. */
export function firstOpenStep(cart: ShopCartDto, hasEmail: boolean): CheckoutStep {
if (!hasEmail) return 'contact'
if (!cart.shippingAddress || !cart.email) return 'address'
if (!cart.shippingMethod) return 'shipping'
return 'payment'
}
@@ -0,0 +1,206 @@
import type { ConfiguratorDefinition } from '@iti/shared'
import {
answerErrorsFromApi,
firstIncompleteStep,
loadWizard,
parseNumber,
preselectionQuestion,
questionError,
readSubmitted,
saveSubmitted,
saveWizard,
stepErrors,
wizardState,
} from './configurator'
/** Ridotta dalla definizione dell'API (apps/api/src/configurator/definition.ts): stessa forma, stesso branching. */
const definition: ConfiguratorDefinition = {
version: 'test-1',
provinces: [
{ code: 'MI', name: 'Milano' },
{ code: 'TO', name: 'Torino' },
],
steps: [
{
id: 'esigenza',
title: 'Di cosa hai bisogno?',
questions: [
{
id: 'percorso',
type: 'single',
label: 'Esigenza',
required: true,
options: [
{ value: 'acqua', label: 'Acqua' },
{ value: 'fotovoltaico', label: 'Fotovoltaico' },
{ value: 'azienda', label: 'Azienda' },
],
},
],
},
{
id: 'acqua',
title: 'La tua acqua',
visibleIf: [{ question: 'percorso', in: ['acqua'] }],
questions: [
{ id: 'persone', type: 'number', label: 'Persone', unit: 'persone', min: 1, max: 12, step: 1, required: true },
{ id: 'nota', type: 'single', label: 'Durezza nota?', required: true, options: [{ value: 'si', label: 'Sì' }, { value: 'no', label: 'No' }] },
{ id: 'durezza', type: 'number', label: 'Durezza', unit: '°f', min: 5, max: 80, step: 1, required: true, visibleIf: [{ question: 'nota', in: ['si'] }] },
],
},
{
id: 'luogo',
title: 'Dove?',
visibleIf: [{ question: 'percorso', in: ['fotovoltaico', 'azienda'] }],
questions: [{ id: 'provincia', type: 'province', label: 'Provincia', required: true }],
},
{
id: 'azienda',
title: 'La tua azienda',
visibleIf: [{ question: 'percorso', in: ['azienda'] }],
questions: [
{
id: 'interessi',
type: 'multi',
label: 'Interessi',
required: true,
minSelected: 1,
options: [
{ value: 'fv', label: 'Fotovoltaico' },
{ value: 'acqua', label: 'Acqua' },
],
},
{ id: 'consumo', type: 'number', label: 'Consumo', unit: 'kWh', min: 5000, max: 5_000_000, step: 1, required: true, visibleIf: [{ question: 'interessi', includes: 'fv' }] },
{ id: 'tetto', type: 'number', label: 'Tetto', unit: 'm²', min: 10, max: 1000, step: 1, required: false },
],
},
],
}
const ids = (def: ConfiguratorDefinition, values: Record<string, string | string[]>) => wizardState(def, values).steps.map((s) => s.id)
describe('configuratore: visibilità e risposte', () => {
it('mostra solo il primo step finché non si sceglie il percorso, poi gli step del percorso', () => {
expect(ids(definition, {})).toEqual(['esigenza'])
expect(ids(definition, { percorso: 'acqua' })).toEqual(['esigenza', 'acqua'])
expect(ids(definition, { percorso: 'azienda' })).toEqual(['esigenza', 'luogo', 'azienda'])
})
it('mostra le domande condizionate solo quando la condizione vale (in / includes)', () => {
const q = (values: Record<string, string | string[]>, step: string) =>
wizardState(definition, values).questions[step]?.map((x) => x.id)
expect(q({ percorso: 'acqua' }, 'acqua')).toEqual(['persone', 'nota'])
expect(q({ percorso: 'acqua', nota: 'si' }, 'acqua')).toEqual(['persone', 'nota', 'durezza'])
expect(q({ percorso: 'azienda', interessi: ['acqua'] }, 'azienda')).toEqual(['interessi', 'tetto'])
expect(q({ percorso: 'azienda', interessi: ['acqua', 'fv'] }, 'azienda')).toEqual(['interessi', 'consumo', 'tetto'])
})
it('invia solo le risposte valide alle domande visibili, con i numeri convertiti', () => {
const { answers } = wizardState(definition, {
percorso: 'acqua',
persone: '4',
nota: 'no',
durezza: '30', // risposta rimasta da una scelta precedente: la domanda non è più visibile
provincia: 'MI', // step di un altro percorso
tetto: '',
})
expect(answers).toEqual({ percorso: 'acqua', persone: 4, nota: 'no' })
})
it('una risposta non valida non rende visibili le domande che dipendono da lei (come il server)', () => {
expect(ids(definition, { percorso: 'inesistente' })).toEqual(['esigenza'])
})
it('una domanda aggiunta nella definizione compare senza modifiche al frontend', () => {
const extended: ConfiguratorDefinition = structuredClone(definition)
extended.steps[1]!.questions.push({
id: 'acqua_pozzo',
type: 'single',
label: "Usi acqua di pozzo?",
required: true,
options: [{ value: 'si', label: 'Sì' }, { value: 'no', label: 'No' }],
})
const state = wizardState(extended, { percorso: 'acqua', persone: '3', nota: 'no' })
expect(state.questions['acqua']?.map((q) => q.id)).toContain('acqua_pozzo')
expect(stepErrors(extended, state, 'acqua', { percorso: 'acqua', persone: '3', nota: 'no' })).toEqual({ acqua_pozzo: 'Scegli una risposta' })
})
})
describe('configuratore: validazione per step', () => {
it('segnala obbligatorie, range, interi, province e scelte multiple', () => {
const values = { percorso: 'azienda', provincia: 'XX', interessi: [] as string[], tetto: '5' }
const state = wizardState(definition, values)
expect(stepErrors(definition, state, 'luogo', values)).toEqual({ provincia: 'Scegli la provincia' })
expect(stepErrors(definition, state, 'azienda', values)).toEqual({
interessi: 'Seleziona almeno 1 opzione',
tetto: 'Inserisci un valore tra 10 e 1.000 m²',
})
const acqua = definition.steps[1]!.questions[0]!
expect(questionError(acqua, '', definition)).toBe('Inserisci un valore')
expect(questionError(acqua, 'tre', definition)).toBe('Inserisci un numero')
expect(questionError(acqua, '2,5', definition)).toBe('Inserisci un numero intero')
expect(questionError(acqua, '13', definition)).toBe('Inserisci un valore tra 1 e 12 persone')
expect(questionError(acqua, ' 3 ', definition)).toBeNull()
})
it('le domande facoltative vuote sono valide', () => {
const values = { percorso: 'azienda', provincia: 'MI', interessi: ['acqua'] }
expect(stepErrors(definition, wizardState(definition, values), 'azienda', values)).toEqual({})
})
it('trova il primo step incompleto', () => {
expect(firstIncompleteStep(definition, {})).toBe(0)
expect(firstIncompleteStep(definition, { percorso: 'acqua', persone: '3' })).toBe(1)
expect(firstIncompleteStep(definition, { percorso: 'acqua', persone: '3', nota: 'no' })).toBe(-1)
})
it('legge numeri in formato italiano', () => {
expect(parseNumber('1.500')).toBe(1500)
expect(parseNumber('1.500,5')).toBe(1500.5)
expect(parseNumber('2,5')).toBe(2.5)
expect(parseNumber('2.5')).toBe(2.5)
expect(parseNumber('3 000')).toBe(3000)
expect(parseNumber('')).toBeNull()
expect(parseNumber('12a')).toBeNull()
})
})
describe('configuratore: preselezione ed errori dell’API', () => {
it('preseleziona solo valori esistenti della prima domanda', () => {
expect(preselectionQuestion(definition, 'fotovoltaico')?.id).toBe('percorso')
expect(preselectionQuestion(definition, 'acqua-calcarea')).toBeNull()
})
it('associa gli errori dell’API alle domande', () => {
expect(
answerErrorsFromApi([
{ path: 'answers.persone', message: 'Valore tra 1 e 12 persone' },
{ path: 'body.answers.nota', message: 'Risposta obbligatoria' },
{ path: 'body.contact.email', message: 'Email non valida' },
]),
).toEqual({ persone: 'Valore tra 1 e 12 persone', nota: 'Risposta obbligatoria' })
})
})
describe('configuratore: sessionStorage', () => {
beforeEach(() => sessionStorage.clear())
it('salva e rilegge lo stato del wizard', () => {
saveWizard({ version: 'test-1', values: { percorso: 'azienda', interessi: ['fv'] }, position: 'luogo' })
expect(loadWizard()).toEqual({ version: 'test-1', values: { percorso: 'azienda', interessi: ['fv'] }, position: 'luogo' })
})
it('ignora dati corrotti', () => {
sessionStorage.setItem('iti-configurator', '{"version":1')
expect(loadWizard()).toBeNull()
sessionStorage.setItem('iti-configurator', JSON.stringify({ version: 'x', position: 'a', values: { n: 3 } }))
expect(loadWizard()).toBeNull()
})
it('conserva il riepilogo della richiesta inviata', () => {
expect(readSubmitted()).toBeNull()
const result = { solution: { code: 'x', title: 'Addolcitore', description: '' } } as never
saveSubmitted({ name: 'Mario', email: 'mario@example.com', result })
expect(readSubmitted()?.result.solution.title).toBe('Addolcitore')
})
})
+246
View File
@@ -0,0 +1,246 @@
import type {
ConfiguratorAnswers,
ConfiguratorCondition,
ConfiguratorDefinition,
ConfiguratorQuestion,
ConfiguratorResult,
ConfiguratorStep,
} from '@iti/shared'
/**
* Logica pura del wizard del configuratore (nessuna dipendenza Angular). Il frontend non conosce le domande:
* step, domande, opzioni e branching arrivano da GET /configurator/definition e qui si interpretano con le
* stesse regole di apps/api/src/configurator/engine.ts (`validateAnswers`):
* - le condizioni `visibleIf` si valutano in ordine, solo sulle risposte già valide delle domande precedenti;
* - una domanda è visibile se lo sono il suo step e lei stessa;
* - all'API si inviano solo le risposte valide alle domande visibili (le altre verrebbero rifiutate).
*/
/** Valori grezzi del form: testo (numeri come digitati, scelta singola, provincia) o elenco (scelta multipla). */
export type WizardValues = Record<string, string | string[]>
/** Errori per domanda (id → messaggio). */
export type WizardErrors = Record<string, string>
function conditionsHold(conditions: ConfiguratorCondition[] | undefined, answers: ConfiguratorAnswers): boolean {
return (conditions ?? []).every((c) => {
const value = answers[c.question]
if (c.in) return typeof value === 'string' && c.in.includes(value)
if (c.includes) return Array.isArray(value) && value.includes(c.includes)
return true
})
}
/** "1.000,5" o "1000,5" → 1000.5; testo vuoto o non numerico → null. */
export function parseNumber(raw: string): number | null {
const text = raw.trim().replace(/\s/g, '')
if (!text) return null
// Con la virgola decimale i punti sono separatori delle migliaia; senza virgola "1.500" è 1500, "2.5" è 2,5
const normalized = text.includes(',')
? text.replace(/\./g, '').replace(',', '.')
: /^\d{1,3}(\.\d{3})+$/.test(text)
? text.replace(/\./g, '')
: text
if (!/^-?\d+(\.\d+)?$/.test(normalized)) return null
return Number(normalized)
}
/** Numero in italiano per i messaggi di range ("tra 500 e 30.000 kWh"). */
const fmt = (n: number) => new Intl.NumberFormat('it-IT', { maximumFractionDigits: 2, useGrouping: 'always' }).format(n)
/** Problema del valore di una domanda visibile (null se valido o se assente e facoltativo). */
export function questionError(q: ConfiguratorQuestion, raw: string | string[] | undefined, definition: ConfiguratorDefinition): string | null {
const empty = raw === undefined || (Array.isArray(raw) ? raw.length === 0 : raw.trim() === '')
if (empty) {
if (!q.required) return null
switch (q.type) {
case 'single':
return 'Scegli una risposta'
case 'multi':
return `Seleziona almeno ${q.minSelected} ${q.minSelected === 1 ? 'opzione' : 'opzioni'}`
case 'province':
return 'Scegli la provincia'
default:
return 'Inserisci un valore'
}
}
switch (q.type) {
case 'single':
return typeof raw === 'string' && q.options.some((o) => o.value === raw) ? null : 'Scegli una risposta'
case 'multi': {
const list = Array.isArray(raw) ? raw : []
if (!list.every((v) => q.options.some((o) => o.value === v))) return 'Opzione non valida'
return list.length >= q.minSelected
? null
: `Seleziona almeno ${q.minSelected} ${q.minSelected === 1 ? 'opzione' : 'opzioni'}`
}
case 'number': {
const value = typeof raw === 'string' ? parseNumber(raw) : null
if (value === null) return 'Inserisci un numero'
if (value < q.min || value > q.max) return `Inserisci un valore tra ${fmt(q.min)} e ${fmt(q.max)} ${q.unit}`
if (q.step > 0 && !Number.isInteger(Math.round((value / q.step) * 1e9) / 1e9)) {
return q.step === 1 ? 'Inserisci un numero intero' : `Inserisci un multiplo di ${fmt(q.step)}`
}
return null
}
case 'province':
return typeof raw === 'string' && definition.provinces.some((p) => p.code === raw) ? null : 'Scegli la provincia'
}
}
/** Valore grezzo → risposta per l'API (numero per le domande numeriche). */
function toAnswer(q: ConfiguratorQuestion, raw: string | string[]): string | number | string[] {
if (q.type === 'number') return parseNumber(raw as string)!
if (q.type === 'multi') return [...(raw as string[])]
return raw as string
}
export interface WizardState {
/** Step visibili, nell'ordine della definizione. */
steps: ConfiguratorStep[]
/** Domande visibili per step (id step → domande). */
questions: Record<string, ConfiguratorQuestion[]>
/** Risposte valide alle domande visibili: ciò che si invia all'API. */
answers: ConfiguratorAnswers
}
/**
* Visibilità di step e domande e risposte da inviare, calcolate in un solo passaggio nell'ordine della
* definizione (come il server): una risposta non valida non rende visibili le domande che dipendono da lei.
*/
export function wizardState(definition: ConfiguratorDefinition, values: WizardValues): WizardState {
const answers: ConfiguratorAnswers = {}
const steps: ConfiguratorStep[] = []
const questions: Record<string, ConfiguratorQuestion[]> = {}
for (const step of definition.steps) {
if (!conditionsHold(step.visibleIf, answers)) continue
const visible: ConfiguratorQuestion[] = []
for (const q of step.questions) {
if (!conditionsHold(q.visibleIf, answers)) continue
visible.push(q)
const raw = values[q.id]
const empty = raw === undefined || (Array.isArray(raw) ? raw.length === 0 : raw.trim() === '')
if (!empty && questionError(q, raw, definition) === null) answers[q.id] = toAnswer(q, raw)
}
steps.push(step)
questions[step.id] = visible
}
return { steps, questions, answers }
}
/** Errori delle domande visibili di uno step (vuoto se lo step è completo). */
export function stepErrors(definition: ConfiguratorDefinition, state: WizardState, stepId: string, values: WizardValues): WizardErrors {
const errors: WizardErrors = {}
for (const q of state.questions[stepId] ?? []) {
const problem = questionError(q, values[q.id], definition)
if (problem) errors[q.id] = problem
}
return errors
}
/** Indice del primo step visibile non completo (o -1 se sono tutti completi). */
export function firstIncompleteStep(definition: ConfiguratorDefinition, values: WizardValues): number {
const state = wizardState(definition, values)
return state.steps.findIndex((s) => Object.keys(stepErrors(definition, state, s.id, values)).length > 0)
}
/**
* Domanda a cui si applica il percorso preselezionato da URL (`?percorso=`): la prima domanda a scelta singola
* del primo step, se tra le sue opzioni c'è il valore richiesto. Nessun id hardcoded nel frontend.
*/
export function preselectionQuestion(definition: ConfiguratorDefinition, value: string): ConfiguratorQuestion | null {
const q = firstChoiceQuestion(definition)
return q?.options.some((o) => o.value === value) ? q : null
}
/** Prima domanda a scelta singola del primo step (di solito decide il percorso). */
export function firstChoiceQuestion(definition: ConfiguratorDefinition): Extract<ConfiguratorQuestion, { type: 'single' }> | null {
const q = definition.steps[0]?.questions.find((x) => x.type === 'single')
return q?.type === 'single' ? q : null
}
/** Errori dell'API per domanda (`details[].path` = "answers.<id>" oppure "body.answers.<id>"). */
export function answerErrorsFromApi(details: { path: string; message: string }[]): WizardErrors {
const errors: WizardErrors = {}
for (const d of details) {
const match = /(?:^|\.)answers\.(.+)$/.exec(d.path)
if (match?.[1] && !errors[match[1]]) errors[match[1]] = d.message
}
return errors
}
// --- Persistenza nella scheda (sessionStorage) ---
const STATE_KEY = 'iti-configurator'
const DONE_KEY = 'iti-configurator-done'
/** Stato del wizard salvato a ogni modifica: il refresh riprende dallo stesso step con le stesse risposte. */
export interface SavedWizard {
/** Versione della definizione con cui sono state date le risposte. */
version: string
values: WizardValues
/** Id dello step corrente, oppure "result" se si stava guardando il risultato. */
position: string
}
function isValues(value: unknown): value is WizardValues {
return (
typeof value === 'object' &&
value !== null &&
Object.values(value).every((v) => typeof v === 'string' || (Array.isArray(v) && v.every((x) => typeof x === 'string')))
)
}
export function loadWizard(): SavedWizard | null {
try {
const raw = sessionStorage.getItem(STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<SavedWizard>
if (typeof parsed.version !== 'string' || typeof parsed.position !== 'string' || !isValues(parsed.values)) return null
return { version: parsed.version, values: parsed.values, position: parsed.position }
} catch {
return null
}
}
export function saveWizard(state: SavedWizard): void {
try {
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch {
// storage non disponibile (modalità privata, quota): il wizard funziona lo stesso, senza ripresa al refresh
}
}
export function clearWizard(): void {
try {
sessionStorage.removeItem(STATE_KEY)
} catch {
// niente da fare
}
}
/** Richiesta inviata: riepilogo per la pagina di ringraziamento (solo in questa scheda, come la conferma d'ordine). */
export interface SubmittedRequest {
name: string
email: string
result: ConfiguratorResult
}
export function saveSubmitted(value: SubmittedRequest): void {
try {
sessionStorage.setItem(DONE_KEY, JSON.stringify(value))
} catch {
// la pagina di ringraziamento mostrerà il messaggio generico
}
}
export function readSubmitted(): SubmittedRequest | null {
try {
const raw = sessionStorage.getItem(DONE_KEY)
if (!raw) return null
const value = JSON.parse(raw) as SubmittedRequest
return typeof value?.email === 'string' && typeof value.result?.solution?.title === 'string' ? value : null
} catch {
return null
}
}
+100
View File
@@ -0,0 +1,100 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common'
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'
import {
CONSENT_COOKIE,
type ConsentCategory,
type ConsentChoice,
consentCookieString,
parseConsent,
readCookie,
} from './consent'
/** Stato del banner: chiuso, banner iniziale, pannello delle preferenze. */
export type ConsentPanel = 'closed' | 'banner' | 'preferences'
/**
* Consenso ai cookie non tecnici. È l'unica fonte da interrogare prima di caricare script o contenuti di terzi
* che usano cookie o tracciano (analytics, pixel, video incorporati…):
*
* ```ts
* const consent = inject(ConsentService)
* effect(() => { if (consent.granted('statistics')) loadAnalytics() }) // reattivo
* await consent.whenGranted('marketing'); loadPixel() // attende il consenso
* ```
*
* - Lato server e prima della lettura del cookie nessuna categoria è concessa: niente parte "per sbaglio".
* - Revocare un consenso già dato non scarica gli script già caricati: chi li carica deve ascoltare
* `granted()` e disattivarli, oppure ricaricare la pagina.
* - Oggi il sito non usa nessuno script non necessario: il servizio c'è perché i futuri lo usino.
*/
@Injectable({ providedIn: 'root' })
export class ConsentService {
private readonly document = inject(DOCUMENT)
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID))
private readonly _choice = signal<ConsentChoice | null>(null)
private readonly _panel = signal<ConsentPanel>('closed')
private readonly waiters: { category: ConsentCategory; resolve: () => void }[] = []
/** Scelta salvata (null = non ancora espressa). */
readonly choice = this._choice.asReadonly()
readonly panel = this._panel.asReadonly()
readonly decided = computed(() => this._choice() !== null)
/** Da chiamare una volta nel browser dopo l'idratazione (app.ts): legge il cookie e, se serve, apre il banner. */
init(): void {
if (!this.isBrowser) return
const choice = parseConsent(readCookie(this.document.cookie, CONSENT_COOKIE))
this._choice.set(choice)
if (!choice) this._panel.set('banner')
this.flushWaiters()
}
granted(category: ConsentCategory): boolean {
return this._choice()?.[category] === true
}
/** Si risolve quando (e se) la categoria viene concessa. */
whenGranted(category: ConsentCategory): Promise<void> {
if (this.granted(category)) return Promise.resolve()
return new Promise((resolve) => this.waiters.push({ category, resolve }))
}
acceptAll(): void {
this.save({ statistics: true, marketing: true })
}
rejectAll(): void {
this.save({ statistics: false, marketing: false })
}
save(choice: { statistics: boolean; marketing: boolean }): void {
const value: ConsentChoice = { ...choice, decidedAt: new Date() }
if (this.isBrowser) {
this.document.cookie = consentCookieString(value, this.document.location.protocol === 'https:')
}
this._choice.set(value)
this._panel.set('closed')
this.flushWaiters()
}
/** Riapre le preferenze (link nel footer e nella cookie policy). */
openPreferences(): void {
this._panel.set('preferences')
}
/** Dal pannello delle preferenze si torna al banner solo se la scelta non è ancora stata fatta. */
closePreferences(): void {
this._panel.set(this._choice() ? 'closed' : 'banner')
}
private flushWaiters(): void {
for (let i = this.waiters.length - 1; i >= 0; i--) {
const w = this.waiters[i]!
if (this.granted(w.category)) {
this.waiters.splice(i, 1)
w.resolve()
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import { TestBed } from '@angular/core/testing'
import { DOCUMENT } from '@angular/common'
import { CONSENT_COOKIE, CONSENT_MAX_AGE_SECONDS, consentCookieString, parseConsent, readCookie, serializeConsent } from './consent'
import { ConsentService } from './consent.service'
import { contactSubject } from './contact'
import { addDays, firstAvailableDay, formatDay, formatTime, formatVisit, freeSlots, peopleOptions } from './showroom'
const clearCookies = () => {
for (const c of document.cookie.split(';')) {
const name = c.split('=')[0]?.trim()
if (name) document.cookie = `${name}=; Path=/; Max-Age=0`
}
}
describe('consenso cookie: formato', () => {
const decidedAt = new Date('2026-10-03T10:00:00Z')
it('serializza e rilegge la scelta', () => {
const value = serializeConsent({ statistics: true, marketing: false, decidedAt })
expect(value).toBe(`1.1.0.${decidedAt.getTime() / 1000}`)
expect(parseConsent(value)).toEqual({ statistics: true, marketing: false, decidedAt })
})
it('ignora valori assenti, malformati o di un’altra versione (banner da rimostrare)', () => {
expect(parseConsent(null)).toBeNull()
expect(parseConsent('')).toBeNull()
expect(parseConsent('1.2.0.123')).toBeNull()
expect(parseConsent('2.1.1.123')).toBeNull()
expect(parseConsent('yes')).toBeNull()
})
it('cookie first-party: Path=/, 6 mesi, SameSite=Lax, Secure solo su https, niente Domain', () => {
const s = consentCookieString({ statistics: false, marketing: false, decidedAt }, true)
expect(s).toContain(`${CONSENT_COOKIE}=1.0.0.`)
expect(s).toContain('Path=/')
expect(s).toContain(`Max-Age=${CONSENT_MAX_AGE_SECONDS}`)
expect(s).toContain('SameSite=Lax')
expect(s).toContain('Secure')
expect(s).not.toMatch(/Domain/i)
expect(consentCookieString({ statistics: false, marketing: false, decidedAt }, false)).not.toContain('Secure')
})
it('legge un cookie tra gli altri', () => {
expect(readCookie('a=1; iti_consent=1.0.0.5; b=2', 'iti_consent')).toBe('1.0.0.5')
expect(readCookie('a=1', 'iti_consent')).toBeNull()
})
})
describe('ConsentService', () => {
beforeEach(() => {
clearCookies()
TestBed.resetTestingModule()
})
it('al primo accesso apre il banner e non concede nulla, senza scrivere cookie', () => {
const consent = TestBed.inject(ConsentService)
consent.init()
expect(consent.panel()).toBe('banner')
expect(consent.granted('statistics')).toBe(false)
expect(consent.granted('marketing')).toBe(false)
expect(document.cookie).not.toContain(CONSENT_COOKIE)
})
it('rifiuto con un clic: chiude il banner, salva la scelta, nessuna categoria concessa', () => {
const consent = TestBed.inject(ConsentService)
consent.init()
consent.rejectAll()
expect(consent.panel()).toBe('closed')
expect(parseConsent(readCookie(document.cookie, CONSENT_COOKIE))).toMatchObject({ statistics: false, marketing: false })
expect(consent.granted('statistics')).toBe(false)
})
it('accettazione con un clic e scelta persistente tra le visite', async () => {
const first = TestBed.inject(ConsentService)
first.init()
const waiting = first.whenGranted('marketing')
first.acceptAll()
await waiting
TestBed.resetTestingModule()
const next = TestBed.inject(ConsentService)
next.init()
expect(next.panel()).toBe('closed')
expect(next.granted('statistics')).toBe(true)
expect(next.granted('marketing')).toBe(true)
})
it('preferenze riapribili e modificabili; chiuderle senza scelta torna al banner', () => {
const consent = TestBed.inject(ConsentService)
consent.init()
consent.openPreferences()
consent.closePreferences()
expect(consent.panel()).toBe('banner')
consent.save({ statistics: true, marketing: false })
consent.openPreferences()
expect(consent.panel()).toBe('preferences')
consent.save({ statistics: false, marketing: false })
expect(consent.granted('statistics')).toBe(false)
consent.openPreferences()
consent.closePreferences()
expect(consent.panel()).toBe('closed')
})
it('il cookie scritto è quello del documento iniettato', () => {
const doc = TestBed.inject(DOCUMENT)
TestBed.inject(ConsentService).acceptAll()
expect(doc.cookie).toContain(`${CONSENT_COOKIE}=1.1.1.`)
})
})
describe('showroom: calendario', () => {
const day = (date: string, available: number[]) => ({
date,
slots: available.map((a, i) => ({ start: `${date}T0${7 + i}:00:00.000Z`, end: `${date}T0${8 + i}:00:00.000Z`, available: a })),
})
it('aggiunge giorni anche a cavallo di mese e anno', () => {
expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
expect(addDays('2026-12-25', 14)).toBe('2027-01-08')
})
it('formatta date e orari nel fuso dello showroom', () => {
expect(formatDay('2026-10-05')).toBe('lunedì 5 ottobre')
expect(formatTime('2026-10-05T07:00:00.000Z', 'Europe/Rome')).toBe('09:00')
// ora solare: +1
expect(formatTime('2026-11-02T08:00:00.000Z', 'Europe/Rome')).toBe('09:00')
expect(formatVisit('2026-10-05T07:00:00.000Z', 'Europe/Rome')).toBe('lunedì 5 ottobre 2026 alle 09:00')
})
it('conta gli orari liberi e trova il primo giorno disponibile', () => {
const data = { timeZone: 'Europe/Rome', slotMinutes: 60, maxPeoplePerBooking: 4, days: [day('2026-10-05', [0, 0]), day('2026-10-06', [0, 3])] }
expect(freeSlots(data.days[0]!)).toBe(0)
expect(freeSlots(data.days[1]!)).toBe(1)
expect(firstAvailableDay(data)).toBe('2026-10-06')
expect(firstAvailableDay({ ...data, days: [] })).toBeNull()
})
it('limita le persone ai posti liberi dello slot', () => {
expect(peopleOptions({ start: '', end: '', available: 2 }, 4)).toEqual([1, 2])
expect(peopleOptions({ start: '', end: '', available: 6 }, 4)).toEqual([1, 2, 3, 4])
expect(peopleOptions(null, 4)).toEqual([1, 2, 3, 4])
})
})
describe('contatti: oggetto precompilato', () => {
it('usa solo gli oggetti previsti, con soluzione o prodotto', () => {
expect(contactSubject('preventivo', null)).toBe('Richiesta di preventivo')
expect(contactSubject('preventivo', 'Addolcitori')).toBe('Richiesta di preventivo: Addolcitori')
expect(contactSubject('candidatura', null)).toBe('Candidatura spontanea')
expect(contactSubject('<script>', 'x')).toBe('')
expect(contactSubject(undefined, 'x')).toBe('')
expect(contactSubject('preventivo', 'x'.repeat(300))).toHaveLength(200)
})
})
+66
View File
@@ -0,0 +1,66 @@
/**
* Consenso cookie: formato del cookie first-party `iti_consent` (funzioni pure, testate in consent.spec.ts).
*
* Valore: `<versione>.<statistiche 0|1>.<marketing 0|1>.<istante della scelta in secondi>`, es. `1.0.0.1790970518`.
* - I cookie necessari non si possono rifiutare e non compaiono nel valore.
* - Cambiando `CONSENT_VERSION` (nuove categorie o nuovi servizi) le scelte precedenti decadono e il banner
* ricompare.
* - Durata 6 mesi: trascorsi, il banner ricompare. È il periodo minimo prima di riproporre il banner
* indicato dalle Linee guida cookie del Garante (10/06/2021).
*/
export const CONSENT_COOKIE = 'iti_consent'
export const CONSENT_VERSION = 1
export const CONSENT_MAX_AGE_SECONDS = 182 * 24 * 3600
/** Categorie facoltative (i necessari sono sempre attivi). */
export const CONSENT_CATEGORIES = ['statistics', 'marketing'] as const
export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]
export interface ConsentChoice {
statistics: boolean
marketing: boolean
/** Istante della scelta. */
decidedAt: Date
}
export function serializeConsent(choice: ConsentChoice): string {
const flag = (v: boolean) => (v ? '1' : '0')
return `${CONSENT_VERSION}.${flag(choice.statistics)}.${flag(choice.marketing)}.${Math.floor(choice.decidedAt.getTime() / 1000)}`
}
/** Valore del cookie → scelta; null se assente, malformato o di una versione diversa (scelta da rifare). */
export function parseConsent(value: string | null | undefined): ConsentChoice | null {
const match = /^(\d+)\.([01])\.([01])\.(\d{1,12})$/.exec(value ?? '')
if (!match || Number(match[1]) !== CONSENT_VERSION) return null
return { statistics: match[2] === '1', marketing: match[3] === '1', decidedAt: new Date(Number(match[4]) * 1000) }
}
/** Legge un cookie da `document.cookie`. */
export function readCookie(cookieHeader: string, name: string): string | null {
for (const part of cookieHeader.split(';')) {
const [key, ...rest] = part.trim().split('=')
if (key === name) {
try {
return decodeURIComponent(rest.join('='))
} catch {
return null
}
}
}
return null
}
/**
* Stringa per `document.cookie`. Il cookie è dell'host corrente (niente Domain) e non è httpOnly: lo legge solo
* il sito. Secure su https.
*/
export function consentCookieString(choice: ConsentChoice, secure: boolean): string {
return [
`${CONSENT_COOKIE}=${serializeConsent(choice)}`,
'Path=/',
`Max-Age=${CONSENT_MAX_AGE_SECONDS}`,
'SameSite=Lax',
...(secure ? ['Secure'] : []),
].join('; ')
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Oggetto precompilato del form contatti dai parametri dell'URL (vedi CLAUDE.md, "Parametri di query"):
* `/contatti?oggetto=preventivo|sopralluogo|installatori|candidatura[&soluzione=<slug>][&prodotto=<handle>]`.
*/
export const CONTACT_TOPICS: Record<string, string> = {
preventivo: 'Richiesta di preventivo',
sopralluogo: 'Prenotazione di un sopralluogo',
installatori: 'Diventare installatore partner',
candidatura: 'Candidatura spontanea',
}
/** Oggetto per il form; "" se il parametro manca o non è tra quelli previsti. `about` = titolo di soluzione o prodotto. */
export function contactSubject(topic: string | undefined, about: string | null): string {
const base = topic ? CONTACT_TOPICS[topic] : undefined
if (!base) return ''
return (about ? `${base}: ${about}` : base).slice(0, 200)
}
+52
View File
@@ -0,0 +1,52 @@
import type { FormTokenDto } from '@iti/shared'
import { inject, Injectable } from '@angular/core'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService } from './api.service'
/**
* Anti-spam dei form pubblici (contatti, showroom), lato client. Il controllo vero lo fa l'API
* (apps/api/src/services/form-guard.ts):
* - token firmato da `GET /forms/token`, chiesto all'apertura del form: l'API scarta in silenzio gli invii
* arrivati prima di `minSeconds`;
* - campo honeypot `website` (componente `app-honeypot`), che un utente non vede e un bot compila.
*
* `submit` aggiunge token e honeypot al body, attende se il token non è ancora utilizzabile e, se l'API risponde
* `FORM_EXPIRED` (token scaduto dopo 24 h o non valido), chiede un nuovo token e riprova una volta.
*/
@Injectable({ providedIn: 'root' })
export class FormGuard {
private readonly api = inject(ApiService)
/** Token corrente con l'istante in cui diventa utilizzabile. */
async issue(): Promise<FormTicket> {
const dto = await firstValueFrom(this.api.get<FormTokenDto>('/forms/token'))
return { token: dto.token, readyAt: Date.now() + dto.minSeconds * 1000 }
}
async submit<T>(
path: string,
body: Record<string, unknown>,
ticket: FormTicket,
honeypot: string,
): Promise<{ response: T; ticket: FormTicket }> {
// Un invio prima dei secondi minimi verrebbe scartato in silenzio dall'API: un utente reale (es. con i campi
// precompilati dall'account) attende i pochi istanti mancanti
const send = async (t: FormTicket) => {
const wait = t.readyAt - Date.now()
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait + 200))
return firstValueFrom(this.api.post<T>(path, { ...body, formToken: t.token, website: honeypot }, { auth: true }))
}
try {
return { response: await send(ticket), ticket }
} catch (err) {
if (!(err instanceof ApiError) || err.code !== 'FORM_EXPIRED') throw err
const fresh = await this.issue()
return { response: await send(fresh), ticket: fresh }
}
}
}
export interface FormTicket {
token: string
readyAt: number
}
+31
View File
@@ -0,0 +1,31 @@
/** Formattazione per l'italiano. */
const euroOptions: Intl.NumberFormatOptions = {
style: 'currency',
currency: 'EUR',
// Il CLDR it-IT non separa le migliaia sotto le 5 cifre ("1190 €"): lo forziamo ("1.190 €")
useGrouping: 'always',
}
const euroWhole = new Intl.NumberFormat('it-IT', { ...euroOptions, maximumFractionDigits: 0 })
const euroCents = new Intl.NumberFormat('it-IT', { ...euroOptions, minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** Centesimi (come nei DTO dell'API) → "1.190 €" se l'importo è intero, altrimenti "992,70 €". */
export function formatEuro(cents: number): string {
return cents % 100 === 0 ? euroWhole.format(cents / 100) : euroCents.format(cents / 100)
}
const longDate = new Intl.DateTimeFormat('it-IT', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome' })
/** Data ISO → "3 ottobre 2026". */
export function formatDate(iso: string): string {
return longDate.format(new Date(iso))
}
const decimal = new Intl.NumberFormat('it-IT', { maximumFractionDigits: 1 })
/** Dimensione di un file → "850 kB", "2,4 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1000) return `${bytes} B`
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} kB`
return `${decimal.format(bytes / 1_000_000)} MB`
}
@@ -0,0 +1,15 @@
import type { HttpInterceptorFn } from '@angular/common/http'
import { inject, REQUEST } from '@angular/core'
import { API_BASE_URL } from './app-config'
/**
* Solo durante l'SSR: inoltra all'API l'`X-Forwarded-For` ricevuto da Caddy (l'IP del visitatore).
* Senza, tutte le chiamate fatte dal server SSR arriverebbero all'API dallo stesso IP (il container del sito)
* e condividerebbero un unico rate limit. L'API si fida dell'intestazione solo dai proxy della rete Docker
* (`TRUST_PROXY=loopback,uniquelocal`). Nel browser `REQUEST` è null: nessun effetto.
*/
export const forwardedForInterceptor: HttpInterceptorFn = (req, next) => {
const forwardedFor = inject(REQUEST, { optional: true })?.headers.get('x-forwarded-for')
if (!forwardedFor || !req.url.startsWith(inject(API_BASE_URL))) return next(req)
return next(req.clone({ setHeaders: { 'X-Forwarded-For': forwardedFor } }))
}
+81
View File
@@ -0,0 +1,81 @@
import type { BlogPostDetailDto } from '@iti/shared'
import { blogListPath } from './blog'
import { listQueryParams, listUrl, normalizeQuery, pageWindow, parsePage, plainTextExcerpt, totalPages } from './listing'
import { blogPostingJsonLd } from './structured-data'
describe('elenchi: URL e paginazione', () => {
it('percorsi di elenco e categoria', () => {
expect(blogListPath()).toBe('/blog')
expect(blogListPath('pompe-di-calore')).toBe('/blog/categoria/pompe-di-calore')
})
it('parametro pagina: assente → 1, valori non validi → null (404)', () => {
expect(parsePage(undefined)).toBe(1)
expect(parsePage('1')).toBe(1)
expect(parsePage('3')).toBe(3)
for (const bad of ['0', '-1', '01', '2.5', 'abc', '99999']) expect(parsePage(bad)).toBeNull()
})
it('ricerca normalizzata e query params senza valori superflui', () => {
expect(normalizeQuery(' pompa di calore ')).toBe('pompa di calore')
expect(normalizeQuery(undefined)).toBe('')
expect(normalizeQuery('abcdef', 3)).toBe('abc')
expect(listQueryParams('', 1)).toEqual({ q: null, pagina: null })
expect(listQueryParams('fv', 2)).toEqual({ q: 'fv', pagina: '2' })
})
it('URL canonici: la pagina 1 non ha il parametro', () => {
expect(listUrl('/blog', '', 1)).toBe('/blog')
expect(listUrl(blogListPath('energia'), '', 2)).toBe('/blog/categoria/energia?pagina=2')
expect(listUrl('/shop', 'a b', 3)).toBe('/shop?q=a+b&pagina=3')
})
it('paginazione: totale pagine e finestra con salti', () => {
expect(totalPages(0, 9)).toBe(1)
expect(totalPages(9, 9)).toBe(1)
expect(totalPages(10, 9)).toBe(2)
expect(totalPages(25, 12)).toBe(3)
expect(pageWindow(1, 1)).toEqual([1])
expect(pageWindow(5, 10)).toEqual([1, null, 4, 5, 6, null, 10])
expect(pageWindow(2, 4)).toEqual([1, 2, 3, 4])
})
it('estratto in testo semplice dal markdown', () => {
expect(plainTextExcerpt('## Titolo\n\nUn **testo** con [link](https://x.it) e ![img](a.png).')).toBe('Titolo Un testo con link e .')
const long = plainTextExcerpt('parola '.repeat(60), 50)
expect(long.length).toBeLessThanOrEqual(50)
expect(long.endsWith('…')).toBe(true)
})
})
describe('JSON-LD BlogPosting', () => {
const post: BlogPostDetailDto = {
id: '1',
slug: 'guida-pompe',
title: 'Guida alle pompe di calore',
excerpt: null,
coverUrl: null,
category: { id: 'c', slug: 'comfort', name: 'Comfort', description: null },
publishedAt: '2026-09-01T08:00:00.000Z',
contentMd: 'testo',
contentHtml: '<p>testo</p>',
seoTitle: 'Pompe di calore: guida',
seoDescription: null,
updatedAt: '2026-09-02T08:00:00.000Z',
}
it('campi principali, immagine di default, editore completo', () => {
const ld = blogPostingJsonLd('https://iti.example', post, 'Descrizione')
expect(ld).toMatchObject({
'@type': 'BlogPosting',
mainEntityOfPage: 'https://iti.example/blog/guida-pompe',
headline: 'Pompe di calore: guida',
description: 'Descrizione',
image: ['https://iti.example/og-default.png'],
datePublished: '2026-09-01T08:00:00.000Z',
dateModified: '2026-09-02T08:00:00.000Z',
articleSection: 'Comfort',
publisher: { '@type': 'Organization', name: 'ITI TECHNOLOGY' },
})
})
})
+71
View File
@@ -0,0 +1,71 @@
/**
* Elenchi paginati con ricerca (blog, shop): stato nell'URL, funzioni pure (testate in listing.spec.ts).
*
* - ricerca: `?q=<testo>` (pagine noindex);
* - paginazione: `?pagina=<n>` da 2 in su (la pagina 1 non ha il parametro: un solo URL canonico).
*/
/**
* Parametro `pagina` → numero di pagina. `undefined` = pagina 1; `null` = valore non valido (non intero, < 1,
* zeri iniziali): la pagina risponde 404. `pagina=1` è accettato, il canonical punta comunque all'URL senza parametro.
*/
export function parsePage(value: string | undefined): number | null {
if (value === undefined || value === '') return 1
return /^[1-9]\d{0,3}$/.test(value) ? Number(value) : null
}
/** Testo di ricerca normalizzato (spazi compattati, lunghezza massima); stringa vuota = nessuna ricerca. */
export function normalizeQuery(value: string | undefined | null, max = 200): string {
return (value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
}
/** Query params di un elenco: `null` rimuove il parametro (convenzione del Router). */
export function listQueryParams(q: string, page: number): { q: string | null; pagina: string | null } {
return { q: q || null, pagina: page > 1 ? String(page) : null }
}
/** URL completo di una pagina dell'elenco (canonical, rel prev/next). */
export function listUrl(basePath: string, q: string, page: number): string {
const params = new URLSearchParams()
if (q) params.set('q', q)
if (page > 1) params.set('pagina', String(page))
const query = params.toString()
return query ? `${basePath}?${query}` : basePath
}
export function totalPages(total: number, pageSize: number): number {
return Math.max(1, Math.ceil(total / pageSize))
}
/**
* Numeri da mostrare nella paginazione: prima e ultima pagina sempre, più `radius` pagine attorno a quella
* corrente; `null` indica un salto ("…").
*/
export function pageWindow(current: number, last: number, radius = 1): (number | null)[] {
const pages: (number | null)[] = []
for (let p = 1; p <= last; p++) {
if (p === 1 || p === last || Math.abs(p - current) <= radius) pages.push(p)
else if (pages.at(-1) !== null) pages.push(null)
}
return pages
}
/**
* Testo semplice da markdown o testo formattato (meta description di riserva): niente sintassi markdown né HTML,
* spazi compattati, taglio sulla parola.
*/
export function plainTextExcerpt(markdown: string, max = 160): string {
const text = markdown
.replace(/```[\s\S]*?```/g, ' ') // blocchi di codice
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // immagini
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // link → testo
.replace(/<[^>]*>/g, ' ') // HTML
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, '') // titoli, citazioni, elenchi
.replace(/[*_`~|]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (text.length <= max) return text
const cut = text.slice(0, max - 1)
const space = cut.lastIndexOf(' ')
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s.,;:–-]+$/, '')}…`
}
@@ -0,0 +1,39 @@
import type { ShopOrderDto } from '@iti/shared'
const KEY = 'iti-last-order'
/** Ultimo ordine completato in questa scheda, per la pagina di conferma. */
export interface LastOrder {
order: ShopOrderDto
/** Ordine fatto senza account: la pagina invita a crearne uno. */
guest: boolean
}
/**
* Il riepilogo dell'ordine resta solo nel sessionStorage della scheda: nessuna route dell'API espone un ordine
* a partire dal suo numero (sarebbe leggibile da chiunque lo indovini). Ricaricando la conferma si rivede il
* riepilogo; da un'altra scheda o dopo la chiusura si vede solo il messaggio generico.
*/
export function saveLastOrder(value: LastOrder): void {
try {
sessionStorage.setItem(KEY, JSON.stringify(value))
} catch {
// storage non disponibile (modalità privata, quota): la pagina mostrerà il messaggio generico
}
}
export function readLastOrder(number: number): LastOrder | null {
try {
const raw = sessionStorage.getItem(KEY)
if (!raw) return null
const value = JSON.parse(raw) as LastOrder
return value?.order?.number === number ? value : null
} catch {
return null
}
}
/** URL della conferma d'ordine. */
export function orderConfirmedPath(number: number): string {
return `/ordine/${number}/confermato`
}
@@ -0,0 +1,13 @@
import { inject, RESPONSE_INIT } from '@angular/core'
/**
* Status HTTP della risposta SSR. Va chiamata nel contesto di iniezione (costruttore); la funzione restituita
* si può usare anche dopo, purché prima che il rendering finisca (es. in un effect che reagisce ai dati
* caricati: il server attende le richieste HTTP in corso prima di inviare la risposta). Nel browser non fa nulla.
*/
export function injectResponseStatus(): (status: number) => void {
const response = inject(RESPONSE_INIT, { optional: true })
return (status) => {
if (response) response.status = status
}
}
@@ -0,0 +1,75 @@
import { DOCUMENT } from '@angular/common'
import { TestBed } from '@angular/core/testing'
import { Title } from '@angular/platform-browser'
import { SITE_CONFIG, type SiteConfig } from './app-config'
import { SeoService } from './seo.service'
import { localBusinessJsonLd, organizationJsonLd } from './structured-data'
const config: SiteConfig = { siteUrl: 'https://iti.example', apiUrl: 'https://api.iti.example', privateAppUrl: 'https://area.iti.example', indexable: true, stripePublishableKey: '' }
function setup(overrides: Partial<SiteConfig> = {}) {
TestBed.configureTestingModule({ providers: [{ provide: SITE_CONFIG, useValue: { ...config, ...overrides } }] })
return { seo: TestBed.inject(SeoService), doc: TestBed.inject(DOCUMENT), title: TestBed.inject(Title) }
}
const meta = (doc: Document, selector: string) => doc.head.querySelector(`meta[${selector}]`)?.getAttribute('content')
describe('SeoService', () => {
afterEach(() => {
const doc = TestBed.inject(DOCUMENT)
doc.head.querySelectorAll('meta, link[rel="canonical"], script[type="application/ld+json"]').forEach((el) => el.remove())
})
it('title, description, canonical e Open Graph', () => {
const { seo, doc, title } = setup()
seo.setPage({ title: 'Blog', description: 'Guide e novità', path: '/blog' })
expect(title.getTitle()).toBe('Blog | ITI TECHNOLOGY')
expect(meta(doc, 'name="description"')).toBe('Guide e novità')
expect(meta(doc, 'name="robots"')).toBe('index, follow')
expect(meta(doc, 'property="og:url"')).toBe('https://iti.example/blog')
expect(meta(doc, 'property="og:title"')).toBe('Blog | ITI TECHNOLOGY')
expect(meta(doc, 'property="og:image"')).toBe('https://iti.example/og-default.png')
expect(doc.head.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://iti.example/blog')
})
it('navigazioni successive aggiornano gli stessi tag, senza duplicati', () => {
const { seo, doc } = setup()
seo.setPage({ title: 'A', description: 'a', path: '/a' })
seo.setPage({ title: 'B', description: 'b', path: '/b', type: 'article' })
expect(doc.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
expect(doc.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1)
expect(meta(doc, 'property="og:type"')).toBe('article')
expect(doc.head.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://iti.example/b')
})
it('noindex: robots noindex e nessun canonical; sito non indicizzabile → noindex ovunque', () => {
const { seo, doc } = setup()
seo.setPage({ title: 'Carrello', description: 'x', path: '/carrello', noindex: true })
expect(meta(doc, 'name="robots"')).toBe('noindex, nofollow')
expect(doc.head.querySelector('link[rel="canonical"]')).toBeNull()
TestBed.resetTestingModule()
const staging = setup({ indexable: false })
staging.seo.setPage({ title: 'Home', description: 'x', path: '/' })
expect(meta(staging.doc, 'name="robots"')).toBe('noindex, nofollow')
})
it('JSON-LD: Organization e LocalBusiness senza dati mancanti, "<" escapato, rimosso alla pagina successiva', () => {
const { seo, doc } = setup()
seo.setPage({ title: 'Home', description: 'x', path: '/' })
seo.setJsonLd('org', [organizationJsonLd(config.siteUrl), localBusinessJsonLd(config.siteUrl)])
const script = doc.getElementById('jsonld-org')!
const data = JSON.parse(script.textContent!) as Record<string, unknown>[]
expect(data[0]).toMatchObject({ '@type': 'Organization', name: 'ITI TECHNOLOGY', url: 'https://iti.example/' })
expect(data[1]).toMatchObject({ '@type': 'LocalBusiness', areaServed: 'IT' })
// campi null (P.IVA, indirizzo, telefono) omessi, mai inventati
expect(data[0]).not.toHaveProperty('vatID')
expect(data[1]).not.toHaveProperty('address')
seo.setJsonLd('test', { name: '</script><script>alert(1)</script>' })
expect(doc.getElementById('jsonld-test')!.textContent).not.toContain('</script>')
seo.setPage({ title: 'Blog', description: 'x', path: '/blog' })
expect(doc.head.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(0)
})
})
+112
View File
@@ -0,0 +1,112 @@
import { DOCUMENT } from '@angular/common'
import { Injectable, inject } from '@angular/core'
import { Meta, Title } from '@angular/platform-browser'
import { SITE_CONFIG } from './app-config'
import { COMPANY } from './site'
export interface PageSeo {
/** Titolo della pagina, senza il nome del sito (viene aggiunto " | ITI TECHNOLOGY"). */
title: string
description: string
/** Percorso della pagina (es. "/blog/slug") per canonical e og:url; default: percorso corrente senza query. */
path?: string
/** Immagine Open Graph (URL assoluto o percorso del sito). */
image?: string
type?: 'website' | 'article' | 'product'
/** true per pagine da non indicizzare (carrello, checkout, 404). */
noindex?: boolean
/** Pagine adiacenti di un elenco paginato (percorsi): `<link rel="prev|next">`. */
prev?: string | null
next?: string | null
}
export type JsonLd = Record<string, unknown>
const SITE_NAME = COMPANY.name
const DEFAULT_IMAGE = '/og-default.png'
/**
* Meta tag SEO di ogni pagina: title, description, robots, canonical, Open Graph, Twitter card, JSON-LD.
* Lavora sul DOCUMENT, quindi i tag sono già nell'HTML renderizzato dal server; nel browser, a ogni
* navigazione, aggiorna gli stessi elementi (nessun duplicato).
*/
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly title = inject(Title)
private readonly meta = inject(Meta)
private readonly document = inject(DOCUMENT)
private readonly config = inject(SITE_CONFIG)
/** URL assoluto sul dominio del sito. */
absoluteUrl(path: string): string {
if (/^https?:\/\//.test(path)) return path
return `${this.config.siteUrl}${path.startsWith('/') ? path : `/${path}`}`
}
setPage(page: PageSeo): void {
const fullTitle = page.title === SITE_NAME ? SITE_NAME : `${page.title} | ${SITE_NAME}`
const url = this.absoluteUrl(page.path ?? this.currentPath())
const image = this.absoluteUrl(page.image ?? DEFAULT_IMAGE)
const robots = page.noindex || !this.config.indexable ? 'noindex, nofollow' : 'index, follow'
this.title.setTitle(fullTitle)
this.setMeta('name', 'description', page.description)
this.setMeta('name', 'robots', robots)
this.setMeta('property', 'og:site_name', SITE_NAME)
this.setMeta('property', 'og:locale', 'it_IT')
this.setMeta('property', 'og:type', page.type ?? 'website')
this.setMeta('property', 'og:title', fullTitle)
this.setMeta('property', 'og:description', page.description)
this.setMeta('property', 'og:url', url)
this.setMeta('property', 'og:image', image)
this.setMeta('name', 'twitter:card', 'summary_large_image')
this.setLink('canonical', page.noindex ? null : url)
this.setLink('prev', page.prev ? this.absoluteUrl(page.prev) : null)
this.setLink('next', page.next ? this.absoluteUrl(page.next) : null)
// Lo structured data è specifico della pagina: la pagina successiva lo reimposta se serve
this.clearJsonLd()
}
/** Inserisce o sostituisce un blocco JSON-LD identificato da `id`. */
setJsonLd(id: string, data: JsonLd | JsonLd[]): void {
const elementId = `jsonld-${id}`
let script = this.document.getElementById(elementId)
if (!script) {
script = this.document.createElement('script')
script.id = elementId
script.setAttribute('type', 'application/ld+json')
script.setAttribute('data-seo', '')
this.document.head.appendChild(script)
}
// "<" escapato: un testo con "</script>" non può chiudere il tag
script.textContent = JSON.stringify(data).replace(/</g, '\\u003c')
}
clearJsonLd(): void {
this.document.head.querySelectorAll('script[type="application/ld+json"][data-seo]').forEach((el) => el.remove())
}
private currentPath(): string {
const location = this.document.location
return location ? location.pathname : '/'
}
private setMeta(attr: 'name' | 'property', key: string, content: string): void {
this.meta.updateTag({ [attr]: key, content }, `${attr}="${key}"`)
}
/** Crea, aggiorna o (con `null`) rimuove `<link rel="…">` nell'head. */
private setLink(rel: 'canonical' | 'prev' | 'next', url: string | null): void {
let link = this.document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
if (!url) {
link?.remove()
return
}
if (!link) {
link = this.document.createElement('link')
link.setAttribute('rel', rel)
this.document.head.appendChild(link)
}
link.setAttribute('href', url)
}
}
@@ -0,0 +1,243 @@
import type { ShopCartDto, ShopProductDetailDto, UserDto } from '@iti/shared'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { firstValueFrom } from 'rxjs'
import { ApiService } from './api.service'
import { API_BASE_URL } from './app-config'
import { authInterceptor } from './auth.interceptor'
import { CartStore } from './cart.store'
import { SessionService } from './session.service'
import { defaultVariant, galleryImages, schemaPrice } from './shop'
import { productJsonLd } from './structured-data'
const API = 'http://api.test'
const tick = () => new Promise((resolve) => setTimeout(resolve))
const user: UserDto = {
id: 'u1',
email: 'inst@example.com',
role: 'installer',
firstName: 'Ada',
lastName: 'Rossi',
companyName: 'Rossi Impianti',
vatNumber: null,
emailVerified: true,
createdAt: '2026-01-01T00:00:00.000Z',
}
function cart(quantity: number): ShopCartDto {
return {
email: null,
items: [
{
id: 'item_1',
variantId: 'variant_1',
productHandle: 'kit',
productTitle: 'Kit',
variantTitle: 'Standard',
sku: null,
thumbnail: null,
quantity,
unitPrice: 100_00,
total: quantity * 100_00,
},
],
itemCount: quantity,
shippingAddress: null,
billingAddress: null,
invoice: null,
shippingMethod: null,
totals: { items: quantity * 100_00, shipping: 0, discount: 0, tax: 0, total: quantity * 100_00 },
readyForPayment: false,
}
}
const setHint = (on: boolean) => {
document.cookie = on ? 'iti_session=1; path=/' : 'iti_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
}
describe('sessione, interceptor e carrello', () => {
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: API_BASE_URL, useValue: API },
],
})
http = TestBed.inject(HttpTestingController)
})
afterEach(() => {
http.verify()
setHint(false)
})
it('senza il cookie indizio: anonimo, nessun refresh', async () => {
const session = TestBed.inject(SessionService)
await session.ready()
expect(session.status()).toBe('anonymous')
http.expectNone(`${API}/auth/refresh`)
})
it('con il cookie indizio: refresh con credenziali, poi il token va solo alle richieste con auth', async () => {
setHint(true)
const session = TestBed.inject(SessionService)
const api = TestBed.inject(ApiService)
const ready = session.ready()
const refresh = http.expectOne(`${API}/auth/refresh`)
expect(refresh.request.withCredentials).toBe(true)
refresh.flush({ accessToken: 'tok-1', expiresIn: 900, user })
await ready
expect(session.status()).toBe('authenticated')
expect(session.priceKey()).toBe('installer:u1')
void firstValueFrom(api.get('/shop/products', { auth: true }))
expect(http.expectOne(`${API}/shop/products`).request.headers.get('Authorization')).toBe('Bearer tok-1')
void firstValueFrom(api.get('/blog/posts'))
expect(http.expectOne(`${API}/blog/posts`).request.headers.has('Authorization')).toBe(false)
http.match(() => true).forEach((r) => r.flush({}))
})
it('401 con token: un refresh e un solo nuovo tentativo con il token nuovo', async () => {
setHint(true)
const session = TestBed.inject(SessionService)
const api = TestBed.inject(ApiService)
const ready = session.ready()
http.expectOne(`${API}/auth/refresh`).flush({ accessToken: 'vecchio', expiresIn: 900, user })
await ready
const result = firstValueFrom(api.get<{ ok: boolean }>('/shop/products/kit', { auth: true }))
http
.expectOne(`${API}/shop/products/kit`)
.flush({ error: { code: 'UNAUTHORIZED', message: 'scaduto' } }, { status: 401, statusText: 'Unauthorized' })
await tick()
http.expectOne(`${API}/auth/refresh`).flush({ accessToken: 'nuovo', expiresIn: 900, user })
await tick()
const retry = http.expectOne(`${API}/shop/products/kit`)
expect(retry.request.headers.get('Authorization')).toBe('Bearer nuovo')
retry.flush({ ok: true })
expect(await result).toEqual({ ok: true })
})
it('refresh rifiutato: utente anonimo', async () => {
setHint(true)
const session = TestBed.inject(SessionService)
const ready = session.ready()
http
.expectOne(`${API}/auth/refresh`)
.flush({ error: { code: 'UNAUTHORIZED', message: 'no' } }, { status: 401, statusText: 'Unauthorized' })
await ready
expect(session.status()).toBe('anonymous')
expect(session.priceKey()).toBe('public')
})
it('carrello: attende la sessione, 404 = vuoto, aggiunta aggiorna il badge', async () => {
setHint(true)
const store = TestBed.inject(CartStore)
store.load()
await tick()
// Prima il refresh: il carrello giusto dipende da chi è l'utente
http.expectNone(`${API}/shop/cart`)
http.expectOne(`${API}/auth/refresh`).flush({ accessToken: 'tok', expiresIn: 900, user })
await tick()
const get = http.expectOne(`${API}/shop/cart`)
expect(get.request.withCredentials).toBe(true)
expect(get.request.headers.get('Authorization')).toBe('Bearer tok')
get.flush({ error: { code: 'CART_NOT_FOUND', message: 'nessun carrello' } }, { status: 404, statusText: 'Not Found' })
await tick()
expect(store.state()).toBe('ready')
expect(store.isEmpty()).toBe(true)
expect(store.error()).toBeNull()
const added = store.add('variant_1', 2)
await tick()
const post = http.expectOne(`${API}/shop/cart/items`)
expect(post.request.body).toEqual({ variantId: 'variant_1', quantity: 2 })
post.flush(cart(2))
expect(await added).toBe(true)
expect(store.itemCount()).toBe(2)
})
it('carrello: due "+" rapidi → +2 (quantità calcolata sul carrello aggiornato)', async () => {
const store = TestBed.inject(CartStore)
const first = store.add('variant_1', 1)
await tick()
http.expectOne(`${API}/shop/cart/items`).flush(cart(1))
await first
const a = store.adjust('item_1', 1)
const b = store.adjust('item_1', 1)
await tick()
const req1 = http.expectOne(`${API}/shop/cart/items/item_1`)
expect(req1.request.body).toEqual({ quantity: 2 })
req1.flush(cart(2))
await tick()
const req2 = http.expectOne(`${API}/shop/cart/items/item_1`)
expect(req2.request.body).toEqual({ quantity: 3 })
req2.flush(cart(3))
expect([await a, await b]).toEqual([true, true])
expect(store.itemCount()).toBe(3)
})
it('carrello: operazioni in coda, una alla volta; errori come messaggio', async () => {
const store = TestBed.inject(CartStore)
const first = store.setQuantity('item_1', 2)
const second = store.setQuantity('item_1', 3)
await tick()
// La seconda parte solo dopo la risposta alla prima
const req1 = http.expectOne(`${API}/shop/cart/items/item_1`)
expect(req1.request.body).toEqual({ quantity: 2 })
req1.flush(cart(2))
await tick()
const req2 = http.expectOne(`${API}/shop/cart/items/item_1`)
expect(req2.request.body).toEqual({ quantity: 3 })
req2.flush({ error: { code: 'OUT_OF_STOCK', message: 'Quantità non disponibile' } }, { status: 409, statusText: 'Conflict' })
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(store.itemCount()).toBe(2)
expect(store.error()).toBe('Quantità non disponibile')
})
})
describe('shop: varianti, immagini, JSON-LD Product', () => {
const product: ShopProductDetailDto = {
handle: 'kit-fv',
title: 'Kit FV',
subtitle: null,
thumbnail: 'https://cdn.test/t.png',
fromPrice: 990_000,
fromOriginalPrice: 990_000,
priceType: 'standard',
categories: [{ handle: 'energia', name: 'Energia' }],
description: 'Kit',
images: [],
variants: [
{ id: 'v1', title: '6 kW', sku: 'FV6', price: 990_000, originalPrice: 990_000, inStock: false, options: [] },
{ id: 'v2', title: '8 kW', sku: 'FV8', price: 1_190_000, originalPrice: 1_190_000, inStock: true, options: [] },
{ id: 'v3', title: 'Su misura', sku: null, price: null, originalPrice: null, inStock: true, options: [] },
],
}
it('variante proposta: la prima disponibile con prezzo; galleria con la miniatura', () => {
expect(defaultVariant(product)?.id).toBe('v2')
expect(galleryImages(product)).toEqual(['https://cdn.test/t.png'])
expect(schemaPrice(549_000)).toBe('5490.00')
})
it('un Offer per variante con prezzo, disponibilità e valuta', () => {
const ld = productJsonLd('https://iti.example', product, 'Descrizione')
expect(ld).toMatchObject({
'@type': 'Product',
url: 'https://iti.example/shop/kit-fv',
name: 'Kit FV',
image: ['https://cdn.test/t.png'],
category: 'Energia',
})
expect(ld['offers']).toEqual([
expect.objectContaining({ sku: 'FV6', price: '9900.00', priceCurrency: 'EUR', availability: 'https://schema.org/OutOfStock' }),
expect.objectContaining({ sku: 'FV8', price: '11900.00', availability: 'https://schema.org/InStock' }),
])
})
})
+118
View File
@@ -0,0 +1,118 @@
import type { UserDto } from '@iti/shared'
import { DOCUMENT, isPlatformBrowser } from '@angular/common'
import { HttpClient } from '@angular/common/http'
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'
import { firstValueFrom } from 'rxjs'
import { API_BASE_URL } from './app-config'
/** Cookie "indizio" impostato dall'API insieme al refresh token (non httpOnly, valore "1", nessun segreto). */
export const SESSION_HINT_COOKIE = 'iti_session'
/** Margine prima della scadenza oltre il quale l'access token si considera già scaduto. */
const EXPIRY_MARGIN_MS = 30_000
/** Nome del Web Lock che serializza i refresh tra le schede dello stesso sito. */
const REFRESH_LOCK = 'iti-auth-refresh'
interface SessionResponse {
accessToken: string
expiresIn: number
user: UserDto
}
export type SessionStatus = 'unknown' | 'anonymous' | 'authenticated'
/**
* Stato di login nell'app pubblica. Il login avviene solo nell'area riservata (apps/private): qui la sessione
* viene ritrovata con il cookie di refresh, condiviso tra i sottodomini (`AUTH_COOKIE_DOMAIN`).
*
* 1. All'avvio nel browser, se c'è il cookie `iti_session` (impostato dall'API al login/refresh, rimosso al logout),
* chiama `POST /auth/refresh` con le credenziali: riceve access token (in memoria, mai in storage) e utente.
* Senza il cookie l'utente è anonimo e non parte nessuna richiesta.
* 2. L'access token dura 15 minuti: alla prima richiesta autenticata dopo la scadenza si fa un nuovo refresh.
* 3. Sul server (SSR) la sessione è sempre anonima: le pagine vengono renderizzate con i prezzi pubblici.
*
* ⚠️ Ogni refresh ruota il token, e due refresh paralleli con lo stesso cookie vengono visti come riuso (sessione
* revocata). Qui i refresh sono serializzati con un Web Lock, che vale tra le schede dello stesso sito; l'area
* riservata è su un'altra origine, quindi un refresh esattamente simultaneo nelle due app resta possibile
* (vedi CLAUDE.md, "Sessione nell'app pubblica").
*/
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly http = inject(HttpClient)
private readonly baseUrl = inject(API_BASE_URL)
private readonly document = inject(DOCUMENT)
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID))
private readonly _status = signal<SessionStatus>(this.isBrowser ? 'unknown' : 'anonymous')
private readonly _user = signal<UserDto | null>(null)
readonly status = this._status.asReadonly()
readonly user = this._user.asReadonly()
/** Ruolo installatore: il catalogo va richiesto con il token (l'API decide se il profilo è approvato). */
readonly isInstaller = computed(() => this._user()?.role === 'installer')
/**
* Chiave dei prezzi del catalogo, da mettere nei parametri delle risorse che li caricano: "public" finché la
* sessione non è nota e per chi non è installatore (stessa risposta dell'SSR, servita dalla transfer cache);
* cambia quando si scopre un installatore, così la risorsa si ricarica con il token e i prezzi riservati.
*/
readonly priceKey = computed(() => (this.isInstaller() ? `installer:${this._user()!.id}` : 'public'))
private token: { value: string; expiresAt: number } | null = null
private initialized: Promise<void> | null = null
private refreshing: Promise<boolean> | null = null
/** Si risolve quando lo stato iniziale è noto (subito sul server e per chi non ha il cookie indizio). */
ready(): Promise<void> {
this.initialized ??= this.init()
return this.initialized
}
/** Access token valido in questo momento, senza attese (null se assente o in scadenza). */
currentToken(): string | null {
return this.token && this.token.expiresAt - EXPIRY_MARGIN_MS > Date.now() ? this.token.value : null
}
/** Nuovo access token dal cookie di refresh; false se la sessione non c'è più (l'utente diventa anonimo). */
refresh(): Promise<boolean> {
if (!this.isBrowser) return Promise.resolve(false)
this.refreshing ??= this.withLock(() => this.doRefresh()).finally(() => (this.refreshing = null))
return this.refreshing
}
private async init(): Promise<void> {
if (!this.isBrowser) return
if (!this.hasHintCookie()) {
this._status.set('anonymous')
return
}
await this.refresh()
}
private async doRefresh(): Promise<boolean> {
try {
const res = await firstValueFrom(
this.http.post<SessionResponse>(`${this.baseUrl}/auth/refresh`, null, { withCredentials: true }),
)
this.token = { value: res.accessToken, expiresAt: Date.now() + res.expiresIn * 1000 }
this._user.set(res.user)
this._status.set('authenticated')
return true
} catch {
// 401 (sessione scaduta o revocata: l'API cancella anche i cookie), rete, rate limit: si prosegue da anonimi
this.token = null
this._user.set(null)
this._status.set('anonymous')
return false
}
}
private hasHintCookie(): boolean {
return this.document.cookie.split(';').some((c) => c.trim() === `${SESSION_HINT_COOKIE}=1`)
}
/** Esegue `fn` sotto il Web Lock condiviso tra le schede, se il browser lo supporta. */
private withLock<T>(fn: () => Promise<T>): Promise<T> {
const locks = (globalThis.navigator as Navigator | undefined)?.locks
return locks ? locks.request(REFRESH_LOCK, fn) : fn()
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { ShopProductDetailDto, ShopVariantDto } from '@iti/shared'
/**
* URL dello shop: elenco `/shop`, per categoria `/shop/categoria/<handle>`, prodotto `/shop/<handle>`;
* ricerca e paginazione come in core/listing.ts.
*/
/** Prodotti per pagina dell'elenco. */
export const SHOP_PAGE_SIZE = 12
/** Lunghezza massima della ricerca (come la validazione dell'API). */
export const SHOP_QUERY_MAX = 100
/** Quantità massima per riga (come la validazione dell'API). */
export const MAX_QUANTITY = 100
export function shopListPath(category?: string | null): string {
return category ? `/shop/categoria/${encodeURIComponent(category)}` : '/shop'
}
export function productPath(handle: string): string {
return `/shop/${encodeURIComponent(handle)}`
}
/** Variante proposta all'apertura: la prima disponibile con un prezzo, altrimenti la prima con un prezzo. */
export function defaultVariant(product: ShopProductDetailDto): ShopVariantDto | undefined {
const priced = product.variants.filter((v) => v.price !== null)
return priced.find((v) => v.inStock) ?? priced[0] ?? product.variants[0]
}
/** Immagini della galleria: quelle del prodotto, altrimenti la miniatura. */
export function galleryImages(product: ShopProductDetailDto): string[] {
if (product.images.length) return product.images
return product.thumbnail ? [product.thumbnail] : []
}
/** Centesimi → prezzo schema.org ("1190.00"). */
export function schemaPrice(cents: number): string {
return (cents / 100).toFixed(2)
}
+57
View File
@@ -0,0 +1,57 @@
import type { ShowroomSlotDto, ShowroomSlotsDto } from '@iti/shared'
/**
* Funzioni pure del calendario showroom (testate in showroom.spec.ts). Gli slot arrivano da GET /showroom/slots
* come istanti ISO; si mostrano nel fuso dello showroom (`timeZone` della risposta), qualunque sia quello del
* browser.
*/
/** Giorni chiesti a ogni pagina del calendario. */
export const SHOWROOM_PAGE_DAYS = 14
/** "2026-10-05" + n giorni → "2026-10-..." (date di calendario, senza fusi). */
export function addDays(date: string, days: number): string {
const d = new Date(`${date}T12:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** Data di calendario "YYYY-MM-DD" → "lunedì 5 ottobre". */
export function formatDay(date: string, options: { short?: boolean } = {}): string {
const fmt = new Intl.DateTimeFormat('it-IT', {
weekday: options.short ? 'short' : 'long',
day: 'numeric',
month: options.short ? 'short' : 'long',
timeZone: 'UTC',
})
return fmt.format(new Date(`${date}T12:00:00Z`))
}
/** Istante ISO → "09:00" nel fuso indicato. */
export function formatTime(iso: string, timeZone: string): string {
return new Intl.DateTimeFormat('it-IT', { hour: '2-digit', minute: '2-digit', timeZone }).format(new Date(iso))
}
/** Istante ISO → "lunedì 5 ottobre 2026 alle 09:00" nel fuso indicato. */
export function formatVisit(iso: string, timeZone: string): string {
const date = new Intl.DateTimeFormat('it-IT', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone }).format(
new Date(iso),
)
return `${date} alle ${formatTime(iso, timeZone)}`
}
/** Posti liberi in tutto il giorno (0 = giorno completo). */
export function freeSlots(day: { slots: ShowroomSlotDto[] }): number {
return day.slots.filter((s) => s.available > 0).length
}
/** Primo giorno con almeno uno slot libero (data), oppure null. */
export function firstAvailableDay(data: ShowroomSlotsDto): string | null {
return data.days.find((d) => freeSlots(d) > 0)?.date ?? null
}
/** Persone prenotabili in uno slot: da 1 al minimo tra posti liberi e massimo per prenotazione. */
export function peopleOptions(slot: ShowroomSlotDto | null, maxPerBooking: number): number[] {
const max = slot ? Math.min(slot.available, maxPerBooking) : maxPerBooking
return Array.from({ length: Math.max(0, max) }, (_, i) => i + 1)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Struttura del sito: voci di menu e percorsi della sitemap. I contenuti veri (aree, soluzioni, pagine azienda,
* home, realizzazioni) stanno in src/content (vedi CLAUDE.md, "Contenuti"). Nessuna dipendenza Angular:
* lo usa anche il server SSR (sitemap.xml).
*/
import { AREA_SLUGS, COMPANY_PAGE_SLUGS, SOLUTION_PATHS } from '../../content/generated/paths'
export { COMPANY } from '../../content/company'
/** Prima pagina azienda, servita su /azienda (l'URL /azienda/chi-siamo reindirizza lì). */
export const COMPANY_HOME_SLUG = 'chi-siamo'
/** Voci del menu principale (oltre alle macro-aree). */
export const MAIN_NAV: { path: string; label: string }[] = [
{ path: '/shop', label: 'Shop' },
{ path: '/blog', label: 'Blog' },
{ path: '/configuratore', label: 'Configuratore' },
{ path: '/showroom', label: 'Showroom' },
{ path: '/contatti', label: 'Contatti' },
]
export const isAreaSlug = (slug: string) => AREA_SLUGS.includes(slug)
export const isSolutionPath = (area: string, slug: string) => SOLUTION_PATHS.some((p) => p.area === area && p.slug === slug)
export const isCompanyPageSlug = (slug: string) => slug !== COMPANY_HOME_SLUG && COMPANY_PAGE_SLUGS.includes(slug)
/**
* Percorsi statici indicizzabili, per la sitemap (blog da /sitemap-data dell'API; prodotti dello shop con il
* prompt 25). Esclusi carrello e checkout (noindex) e /azienda/chi-siamo (duplicato di /azienda).
*/
export function staticSitemapPaths(): string[] {
return [
'/',
'/azienda',
...COMPANY_PAGE_SLUGS.filter((s) => s !== COMPANY_HOME_SLUG).map((s) => `/azienda/${s}`),
...AREA_SLUGS.map((a) => `/soluzioni/${a}`),
...SOLUTION_PATHS.map((p) => `/soluzioni/${p.area}/${p.slug}`),
'/shop',
'/blog',
'/configuratore',
'/realizzazioni',
'/showroom',
'/contatti',
'/privacy',
'/cookie',
]
}
+35
View File
@@ -0,0 +1,35 @@
import type { Appearance, Stripe } from '@stripe/stripe-js'
let loading: Promise<Stripe | null> | null = null
/**
* Stripe.js caricato solo quando serve (passo "Pagamento" del checkout, nel browser): la variante `pure` non
* inserisce lo script di js.stripe.com finché non si chiama loadStripe, quindi le altre pagine non lo scaricano.
* Restituisce null se lo script non si carica (rete, blocchi del browser).
*/
export function loadStripeClient(publishableKey: string): Promise<Stripe | null> {
loading ??= import('@stripe/stripe-js/pure')
.then(({ loadStripe }) => loadStripe(publishableKey, { locale: 'it' }))
.catch(() => {
loading = null
return null
})
return loading
}
/** Aspetto del Payment Element allineato ai token del sito (font di sistema, blu brand, raggi). */
export const STRIPE_APPEARANCE: Appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#0b5c7a',
colorText: '#12212b',
colorTextSecondary: '#4a5a66',
colorDanger: '#b42318',
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
borderRadius: '8px',
spacingUnit: '4px',
},
}
/** Stati del PaymentIntent per cui il pagamento è riuscito (o autorizzato) e l'ordine si può completare. */
export const PAYMENT_OK_STATUSES: readonly string[] = ['succeeded', 'requires_capture', 'processing']
+166
View File
@@ -0,0 +1,166 @@
import type { JsonLd } from './seo.service'
import { productPath, schemaPrice } from './shop'
import { COMPANY } from './site'
import type { BlogPostDetailDto, ShopProductDetailDto } from '@iti/shared'
import type { Faq } from '../../content/types'
/** Structured data schema.org dell'azienda. I dati aziendali mancanti (null in COMPANY) vengono omessi. */
const compact = (obj: Record<string, unknown>): JsonLd =>
Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== null && v !== undefined && !(Array.isArray(v) && v.length === 0)))
export function organizationJsonLd(siteUrl: string): JsonLd {
return compact({
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${siteUrl}/#organization`,
name: COMPANY.name,
legalName: COMPANY.legalName,
url: `${siteUrl}/`,
logo: `${siteUrl}/logo.svg`,
description: COMPANY.description,
email: COMPANY.email,
telephone: COMPANY.phone,
vatID: COMPANY.vatNumber,
sameAs: COMPANY.sameAs,
})
}
export function localBusinessJsonLd(siteUrl: string): JsonLd {
const address = COMPANY.address
return compact({
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
'@id': `${siteUrl}/#localbusiness`,
name: COMPANY.name,
url: `${siteUrl}/`,
image: `${siteUrl}/og-default.png`,
description: COMPANY.description,
parentOrganization: { '@id': `${siteUrl}/#organization` },
email: COMPANY.email,
telephone: COMPANY.phone,
address: address
? {
'@type': 'PostalAddress',
streetAddress: address.street,
postalCode: address.postalCode,
addressLocality: address.city,
addressRegion: address.province,
addressCountry: address.country,
}
: null,
geo: COMPANY.geo ? { '@type': 'GeoCoordinates', latitude: COMPANY.geo.lat, longitude: COMPANY.geo.lng } : null,
openingHours: COMPANY.openingHours,
areaServed: 'IT',
})
}
export function websiteJsonLd(siteUrl: string): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${siteUrl}/#website`,
name: COMPANY.name,
url: `${siteUrl}/`,
inLanguage: 'it-IT',
publisher: { '@id': `${siteUrl}/#organization` },
}
}
/** FAQPage (rich result delle domande frequenti). Solo testo semplice nelle risposte. */
export function faqJsonLd(faq: readonly Faq[]): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faq.map((f) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
}
}
/** BreadcrumbList: Home + voci; l'ultima voce è la pagina corrente. */
export function breadcrumbJsonLd(siteUrl: string, items: readonly { name: string; path: string }[]): JsonLd {
const all = [{ name: 'Home', path: '/' }, ...items]
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: all.map((item, i) => ({
'@type': 'ListItem',
position: i + 1,
name: item.name,
item: `${siteUrl}${item.path === '/' ? '/' : item.path}`,
})),
}
}
/**
* BlogPosting (sottotipo di Article) di un articolo del blog. L'API non espone l'autore: l'articolo è
* attribuito all'azienda, che ne è anche l'editore.
*/
export function blogPostingJsonLd(siteUrl: string, post: BlogPostDetailDto, description: string): JsonLd {
const url = `${siteUrl}/blog/${encodeURIComponent(post.slug)}`
// Oggetto completo (non solo @id): l'Organization non è dichiarata in questa pagina
const organization = {
'@type': 'Organization',
'@id': `${siteUrl}/#organization`,
name: COMPANY.name,
url: `${siteUrl}/`,
logo: { '@type': 'ImageObject', url: `${siteUrl}/logo.svg` },
}
return compact({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `${url}#article`,
mainEntityOfPage: url,
url,
headline: post.seoTitle ?? post.title,
name: post.title,
description,
image: post.coverUrl ? [post.coverUrl] : [`${siteUrl}/og-default.png`],
datePublished: post.publishedAt,
dateModified: post.updatedAt,
articleSection: post.category?.name ?? null,
inLanguage: 'it-IT',
author: organization,
publisher: organization,
})
}
/**
* Product con un'offerta per variante (prezzo IVA inclusa in EUR, disponibilità). Le varianti senza prezzo sono
* omesse. Va generato solo con i prezzi pubblici: quelli riservati agli installatori non sono un'offerta pubblica.
*/
export function productJsonLd(siteUrl: string, product: ShopProductDetailDto, description: string): JsonLd {
const url = `${siteUrl}${productPath(product.handle)}`
const images = product.images.length ? product.images : product.thumbnail ? [product.thumbnail] : []
const offers = product.variants
.filter((v) => v.price !== null)
.map((v) =>
compact({
'@type': 'Offer',
url,
sku: v.sku,
name: product.variants.length > 1 ? `${product.title} - ${v.title}` : null,
price: schemaPrice(v.price!),
priceCurrency: 'EUR',
availability: v.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
itemCondition: 'https://schema.org/NewCondition',
seller: { '@type': 'Organization', name: COMPANY.name },
}),
)
const firstSku = product.variants.length === 1 ? product.variants[0]?.sku : null
return compact({
'@context': 'https://schema.org',
'@type': 'Product',
'@id': `${url}#product`,
url,
name: product.title,
description,
image: images,
sku: firstSku ?? null,
category: product.categories[0]?.name ?? null,
offers,
})
}
+181
View File
@@ -0,0 +1,181 @@
import { DOCUMENT } from '@angular/common'
import { afterNextRender, ChangeDetectionStrategy, Component, effect, inject, Injector, signal } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ConsentService } from '../core/consent.service'
/**
* Banner cookie (nessuna libreria esterna). Compare solo nel browser, finché la scelta non è fatta.
* - "Rifiuta" e "Accetta tutti" hanno lo stesso peso visivo e chiudono con un clic; "Personalizza" apre le categorie.
* - Le preferenze si riaprono dal footer ("Preferenze cookie") e dalla cookie policy.
* - Non è modale: il sito resta navigabile, ma nessuno script non necessario parte finché non c'è il consenso
* (vedi ConsentService).
*/
@Component({
selector: 'app-cookie-banner',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@switch (consent.panel()) {
@case ('banner') {
<section class="banner" role="region" aria-labelledby="cookie-title">
<div class="inner">
<div class="text">
<h2 id="cookie-title">Rispettiamo la tua privacy</h2>
<p>
Usiamo solo cookie tecnici, necessari al funzionamento del sito (accesso, carrello, preferenze). Con il tuo
consenso potremmo usare anche cookie statistici e di marketing: puoi accettarli, rifiutarli o sceglierli
uno per uno. Dettagli nella <a routerLink="/cookie">cookie policy</a>.
</p>
</div>
<div class="buttons">
<button type="button" class="btn btn--choice" (click)="consent.rejectAll()">Rifiuta</button>
<button type="button" class="btn btn--choice" (click)="consent.acceptAll()">Accetta tutti</button>
<button type="button" class="btn btn--outline" (click)="customize()">Personalizza</button>
</div>
</div>
</section>
}
@case ('preferences') {
<section
class="banner"
role="dialog"
aria-labelledby="cookie-prefs-title"
aria-describedby="cookie-prefs-desc"
(keydown.escape)="consent.closePreferences()"
>
<form class="inner inner--prefs" (submit)="$event.preventDefault(); save()">
<h2 id="cookie-prefs-title" tabindex="-1">Preferenze cookie</h2>
<p id="cookie-prefs-desc">
Scegli quali categorie di cookie consentire. Puoi cambiare idea in qualsiasi momento dal link "Preferenze
cookie" in fondo a ogni pagina.
</p>
<ul class="categories">
<li>
<label class="check">
<input type="checkbox" checked disabled />
<span>
<strong>Necessari</strong> (sempre attivi) — accesso all'area riservata, carrello, memorizzazione di
questa scelta. Senza, il sito non funziona.
</span>
</label>
</li>
<li>
<label class="check">
<input #stats type="checkbox" [checked]="statistics()" (change)="statistics.set(stats.checked)" />
<span><strong>Statistiche</strong> — misurano in forma aggregata come viene usato il sito. Oggi non ne usiamo.</span>
</label>
</li>
<li>
<label class="check">
<input #mkt type="checkbox" [checked]="marketing()" (change)="marketing.set(mkt.checked)" />
<span>
<strong>Marketing</strong> — contenuti di terze parti e pubblicità personalizzata. Oggi non ne usiamo.
</span>
</label>
</li>
</ul>
<div class="buttons">
<button type="submit" class="btn btn--primary">Salva preferenze</button>
<button type="button" class="btn btn--choice" (click)="consent.rejectAll()">Rifiuta tutti</button>
<button type="button" class="btn btn--choice" (click)="consent.acceptAll()">Accetta tutti</button>
</div>
</form>
</section>
}
}
`,
styles: `
@use 'mixins' as mq;
.banner {
position: fixed;
inset: auto var(--space-3) var(--space-3);
z-index: 50;
max-height: calc(100vh - 2 * var(--space-3));
overflow-y: auto;
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-lg);
@include mq.up(md) {
inset: auto var(--space-5) var(--space-5);
}
}
.inner {
display: grid;
gap: var(--space-4);
max-width: 72rem;
margin-inline: auto;
padding: var(--space-4) var(--space-5);
@include mq.up(lg) {
grid-template-columns: 1fr auto;
align-items: center;
}
&--prefs {
display: block;
max-width: 48rem;
}
}
h2 {
margin-bottom: var(--space-2);
font-size: var(--font-size-lg);
&:focus {
outline: none;
}
}
p {
margin: 0;
font-size: var(--font-size-sm);
}
.categories {
margin: var(--space-3) 0;
padding: 0;
list-style: none;
font-size: var(--font-size-sm);
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* Rifiuta e Accetta: stesso stile e stessa evidenza */
.btn--choice {
background: var(--color-primary);
color: var(--color-on-primary);
&:hover {
background: var(--color-primary-strong);
}
}
`,
})
export class CookieBanner {
protected readonly consent = inject(ConsentService)
private readonly document = inject(DOCUMENT)
private readonly injector = inject(Injector)
protected readonly statistics = signal(false)
protected readonly marketing = signal(false)
constructor() {
// All'apertura delle preferenze: caselle sulla scelta attuale, focus sul titolo
effect(() => {
if (this.consent.panel() !== 'preferences') return
const choice = this.consent.choice()
this.statistics.set(choice?.statistics ?? false)
this.marketing.set(choice?.marketing ?? false)
afterNextRender(() => this.document.getElementById('cookie-prefs-title')?.focus(), { injector: this.injector })
})
}
protected customize(): void {
this.consent.openPreferences()
}
protected save(): void {
this.consent.save({ statistics: this.statistics(), marketing: this.marketing() })
}
}
+169
View File
@@ -0,0 +1,169 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { SITE_CONFIG } from '../core/app-config'
import { ConsentService } from '../core/consent.service'
import { AREAS } from '../../content/areas'
import { COMPANY_PAGES, companyPagePath } from '../../content/company-pages'
import { COMPANY } from '../core/site'
/** Footer: dati aziendali (solo quelli valorizzati in COMPANY), link alle aree, privacy, cookie e preferenze cookie. */
@Component({
selector: 'app-footer',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<footer class="footer">
<div class="container grid">
<section aria-labelledby="footer-company">
<h2 id="footer-company" class="title">{{ company.name }}</h2>
<p>{{ company.description }}</p>
<address>
@if (company.legalName) {
<span>{{ company.legalName }}</span>
}
@if (company.address; as a) {
<span>{{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }})</span>
}
@if (company.phone) {
<span>Tel. <a [href]="'tel:' + company.phone">{{ company.phone }}</a></span>
}
@if (company.email) {
<span><a [href]="'mailto:' + company.email">{{ company.email }}</a></span>
}
@if (company.vatNumber) {
<span>P.IVA {{ company.vatNumber }}</span>
}
</address>
</section>
<nav aria-labelledby="footer-solutions">
<h2 id="footer-solutions" class="title">Soluzioni</h2>
<ul>
@for (area of areas; track area.slug) {
<li><a [routerLink]="['/soluzioni', area.slug]">{{ area.title }}</a></li>
}
</ul>
</nav>
<nav aria-labelledby="footer-company-links">
<h2 id="footer-company-links" class="title">Azienda</h2>
<ul>
@for (page of companyPages; track page.slug) {
<li><a [routerLink]="pagePath(page)">{{ page.title }}</a></li>
}
<li><a routerLink="/realizzazioni">Realizzazioni</a></li>
<li><a routerLink="/showroom">Showroom</a></li>
<li><a routerLink="/contatti">Contatti</a></li>
</ul>
</nav>
<nav aria-labelledby="footer-services">
<h2 id="footer-services" class="title">Servizi</h2>
<ul>
<li><a routerLink="/shop">Shop</a></li>
<li><a routerLink="/configuratore">Configuratore</a></li>
<li><a routerLink="/blog">Blog</a></li>
<li><a [href]="privateAppUrl">Area riservata</a></li>
</ul>
</nav>
</div>
<div class="container legal">
<p>© {{ year }} {{ company.legalName ?? company.name }}. Tutti i diritti riservati.</p>
<ul>
<li><a routerLink="/privacy">Privacy policy</a></li>
<li><a routerLink="/cookie">Cookie policy</a></li>
<li><button type="button" class="link" (click)="consent.openPreferences()">Preferenze cookie</button></li>
</ul>
</div>
</footer>
`,
styles: `
@use 'mixins' as mq;
.footer {
margin-top: auto;
padding-top: var(--space-7);
background: var(--color-ink);
color: #d5dee4;
font-size: var(--font-size-sm);
a {
display: inline-block;
padding-block: var(--space-1);
color: #ffffff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.grid {
display: grid;
gap: var(--space-6);
@include mq.up(md) {
grid-template-columns: repeat(2, 1fr);
}
@include mq.up(lg) {
grid-template-columns: 2fr 1fr 1fr 1fr;
}
}
.title {
margin-bottom: var(--space-3);
color: #ffffff;
font-size: var(--font-size-md);
}
address {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-style: normal;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
.legal {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-7);
padding-block: var(--space-4);
border-top: 1px solid rgb(255 255 255 / 15%);
p {
margin: 0;
}
ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
}
}
.link {
padding: var(--space-1) 0;
border: 0;
background: none;
color: #ffffff;
font: inherit;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
`,
})
export class Footer {
protected readonly consent = inject(ConsentService)
protected readonly company = COMPANY
protected readonly areas = AREAS
protected readonly companyPages = COMPANY_PAGES
protected readonly pagePath = companyPagePath
protected readonly privateAppUrl = inject(SITE_CONFIG).privateAppUrl
protected readonly year = new Date().getFullYear()
}
+279
View File
@@ -0,0 +1,279 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { filter } from 'rxjs'
import { SITE_CONFIG } from '../core/app-config'
import { CartStore } from '../core/cart.store'
import { AREAS } from '../../content/areas'
import { MAIN_NAV } from '../core/site'
/**
* Header: logo, macro-aree (Acqua, Energia, Comfort, Mobilità, Industria), Shop, Blog, Configuratore,
* Showroom, Contatti, link all'area riservata e carrello con badge (sempre visibile, anche su mobile). Sotto `xl`
* il menu si apre con il pulsante hamburger.
*/
@Component({
selector: 'app-header',
imports: [RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="header">
<div class="container bar">
<a class="logo" routerLink="/" aria-label="ITI TECHNOLOGY, home page">
<img src="/logo.svg" alt="" width="36" height="36" />
<span>ITI <strong>TECHNOLOGY</strong></span>
</a>
<a class="cart" routerLink="/carrello" routerLinkActive="active" [attr.aria-label]="cartLabel()">
<svg aria-hidden="true" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="20" r="1.5" /><circle cx="18" cy="20" r="1.5" />
<path d="M2 3h3l2.6 12.4a2 2 0 0 0 2 1.6h8.2a2 2 0 0 0 2-1.6L21.5 7H6" />
</svg>
@if (cartCount() > 0) {
<span class="badge" aria-hidden="true">{{ cartCount() > 99 ? '99+' : cartCount() }}</span>
}
</a>
<button
type="button"
class="toggle"
[attr.aria-expanded]="open()"
aria-controls="main-menu"
(click)="open.set(!open())"
>
<span class="toggle__icon" aria-hidden="true"></span>
<span class="visually-hidden">{{ open() ? 'Chiudi il menu' : 'Apri il menu' }}</span>
</button>
<nav id="main-menu" class="menu" [class.menu--open]="open()" aria-label="Menu principale">
<ul class="areas">
@for (area of areas; track area.slug) {
<li>
<a [routerLink]="['/soluzioni', area.slug]" routerLinkActive="active" [attr.data-area]="area.slug">{{ area.label }}</a>
</li>
}
</ul>
<ul class="links">
@for (item of nav; track item.path) {
<li><a [routerLink]="item.path" routerLinkActive="active">{{ item.label }}</a></li>
}
</ul>
<a class="btn btn--primary reserved" [href]="privateAppUrl">Area riservata</a>
</nav>
</div>
</header>
`,
styles: `
@use 'mixins' as mq;
.header {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-line);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--header-height);
gap: var(--space-4);
/* Su desktop logo, menu e carrello stanno su una riga: spazi ridotti per lasciare margine */
@include mq.up(xl) {
gap: var(--space-3);
}
}
.logo {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary);
font-size: var(--font-size-lg);
text-decoration: none;
letter-spacing: 0.02em;
white-space: nowrap;
}
.cart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-left: auto;
border-radius: var(--radius-md);
color: var(--color-ink);
&:hover,
&.active {
color: var(--color-primary);
}
@include mq.up(xl) {
order: 1;
margin-left: 0;
}
}
.badge {
position: absolute;
top: 2px;
right: 0;
min-width: 20px;
height: 20px;
padding-inline: 5px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 0.75rem;
font-weight: var(--font-weight-bold);
line-height: 20px;
text-align: center;
}
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
@include mq.up(xl) {
display: none;
}
}
.toggle__icon,
.toggle__icon::before,
.toggle__icon::after {
display: block;
width: 22px;
height: 2px;
background: var(--color-ink);
border-radius: 2px;
}
.toggle__icon {
position: relative;
&::before,
&::after {
content: '';
position: absolute;
left: 0;
}
&::before {
top: -7px;
}
&::after {
top: 7px;
}
}
.menu {
display: none;
position: absolute;
inset: var(--header-height) 0 auto;
padding: var(--space-4) var(--gutter) var(--space-5);
background: var(--color-surface);
border-bottom: 1px solid var(--color-line);
box-shadow: var(--shadow-md);
&--open {
display: block;
}
@include mq.up(xl) {
display: flex;
position: static;
align-items: center;
gap: var(--space-4);
padding: 0;
border: 0;
box-shadow: none;
}
}
ul {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
@include mq.up(xl) {
flex-direction: row;
gap: var(--space-1);
}
}
.areas {
padding-bottom: var(--space-3);
margin-bottom: var(--space-3);
border-bottom: 1px solid var(--color-line);
@include mq.up(xl) {
padding: 0 var(--space-3) 0 0;
margin: 0;
border-bottom: 0;
border-right: 1px solid var(--color-line);
}
}
ul a {
display: flex;
align-items: center;
min-height: 44px;
padding-inline: var(--space-2);
border-radius: var(--radius-sm);
color: var(--color-ink);
font-weight: var(--font-weight-medium);
text-decoration: none;
white-space: nowrap;
@include mq.up(xl) {
font-size: var(--font-size-sm);
}
&:hover,
&.active {
color: var(--color-primary);
}
&.active {
text-decoration: underline;
text-decoration-thickness: 2px;
}
}
.reserved {
margin-top: var(--space-4);
width: 100%;
white-space: nowrap;
@include mq.up(xl) {
margin: 0;
width: auto;
padding-inline: var(--space-4);
}
}
`,
})
export class Header {
protected readonly areas = AREAS
protected readonly nav = MAIN_NAV
protected readonly privateAppUrl = inject(SITE_CONFIG).privateAppUrl
protected readonly open = signal(false)
/** Numero di pezzi nel carrello: 0 durante l'SSR e finché il carrello non è caricato nel browser. */
protected readonly cartCount = inject(CartStore).itemCount
protected readonly cartLabel = computed(() => {
const n = this.cartCount()
return n === 0 ? 'Carrello' : n === 1 ? 'Carrello, 1 prodotto' : `Carrello, ${n} prodotti`
})
constructor() {
// Il menu mobile si chiude a ogni navigazione
inject(Router)
.events.pipe(
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.open.set(false))
}
}
+232
View File
@@ -0,0 +1,232 @@
import type { BlogPostDetailDto, BlogPostSummaryDto, Paginated } from '@iti/shared'
import { NgOptimizedImage } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { DomSanitizer } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { catchError, map, of } from 'rxjs'
import { ApiError, ApiService } from '../core/api.service'
import { SITE_CONFIG } from '../core/app-config'
import { blogListPath } from '../core/blog'
import { plainTextExcerpt } from '../core/listing'
import { formatDate } from '../core/format'
import { injectResponseStatus } from '../core/response-status'
import { SeoService } from '../core/seo.service'
import { blogPostingJsonLd, breadcrumbJsonLd } from '../core/structured-data'
import { NotFoundView } from '../shared/not-found-view'
import { PageHeader } from '../shared/page-header'
import { PostCard } from '../shared/post-card'
import { QuoteCta } from '../shared/quote-cta'
const RELATED_COUNT = 3
/**
* /blog/:slug: articolo da GET /blog/posts/:slug. Slug inesistente (o articolo in bozza/programmato) → 404 reale.
*
* `contentHtml` arriva già sanitizzato dall'API (sanitize-html con whitelist, vedi CLAUDE.md "Blog"), che resta
* l'unico punto di sanitizzazione: il sanitizer di Angular toglierebbe attributi ammessi come `loading="lazy"`.
*/
@Component({
selector: 'app-blog-post-page',
imports: [NgOptimizedImage, RouterLink, PageHeader, PostCard, QuoteCta, NotFoundView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (notFound()) {
<app-not-found-view heading="Articolo non trovato" lead="L’articolo che cerchi non esiste o non è più disponibile." />
} @else if (failed()) {
<app-page-header heading="Blog" [crumbs]="[{ label: 'Blog', path: '/blog' }]" />
<div class="container body">
<p class="notice">Non è stato possibile caricare l’articolo. Riprova tra qualche istante.</p>
</div>
} @else if (post(); as p) {
<article>
<app-page-header [heading]="p.title" [lead]="p.excerpt ?? undefined" [crumbs]="crumbs()">
<p class="meta">
@if (p.publishedAt) {
<time [attr.datetime]="p.publishedAt">{{ date(p.publishedAt) }}</time>
}
@if (p.category) {
<a [routerLink]="categoryPath()">{{ p.category.name }}</a>
}
</p>
</app-page-header>
<div class="container body">
@if (p.coverUrl) {
<div class="cover">
<img [ngSrc]="p.coverUrl" fill priority alt="" />
</div>
}
<div class="prose" [innerHTML]="contentHtml()"></div>
</div>
</article>
<div class="container">
<app-quote-cta
[showConfigurator]="true"
heading="Quale soluzione fa per te?"
text="Rispondi a poche domande nel configuratore: ricevi una stima indicativa gratuita, senza impegno."
/>
@if (related().length) {
<section class="related" aria-labelledby="related-title">
<h2 id="related-title">{{ p.category ? 'Altri articoli su ' + p.category.name : 'Altri articoli' }}</h2>
<ul>
@for (r of related(); track r.id) {
<li><app-post-card [post]="r" /></li>
}
</ul>
</section>
}
</div>
}
`,
styles: `
@use 'mixins' as mq;
@use 'prose';
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
a {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: var(--font-weight-semibold);
color: var(--color-secondary);
}
}
.body {
padding-block: var(--space-5) var(--space-4);
}
.cover {
position: relative;
max-width: 60rem;
aspect-ratio: 16 / 9;
margin-bottom: var(--space-6);
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
img {
object-fit: cover;
}
}
.notice {
padding: var(--space-5);
border-left: 4px solid var(--color-danger);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
}
.related {
margin-block: var(--space-7) var(--space-8);
ul {
display: grid;
gap: var(--space-5);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(3, 1fr);
}
}
}
/* Corpo dell'articolo: HTML generato dal markdown (nessun attributo di incapsulamento → ::ng-deep) */
.prose {
@include prose.container;
}
.prose ::ng-deep {
@include prose.content;
}
`,
})
export default class BlogPostPage {
readonly slug = input.required<string>()
private readonly api = inject(ApiService)
private readonly sanitizer = inject(DomSanitizer)
private readonly postRes = rxResource({
params: () => this.slug(),
stream: ({ params }) => this.api.get<BlogPostDetailDto>(`/blog/posts/${encodeURIComponent(params)}`),
})
protected readonly post = computed(() => (this.postRes.hasValue() ? this.postRes.value() : undefined))
private readonly error = computed(() => this.postRes.error())
protected readonly notFound = computed(() => {
const err = this.error()
return err instanceof ApiError && err.status === 404
})
protected readonly failed = computed(() => this.error() !== undefined && !this.notFound())
/** Fidato: sanitizzato dall'API (vedi commento della classe). */
protected readonly contentHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.post()?.contentHtml ?? ''))
protected readonly categoryPath = computed(() => blogListPath(this.post()?.category?.slug))
protected readonly crumbs = computed(() => {
const p = this.post()
if (!p) return []
return p.category
? [{ label: 'Blog', path: '/blog' }, { label: p.category.name, path: blogListPath(p.category.slug) }, { label: p.title }]
: [{ label: 'Blog', path: '/blog' }, { label: p.title }]
})
/** Correlati: stessa categoria (oppure i più recenti se l'articolo non ne ha), escluso l'articolo corrente. */
private readonly relatedRes = rxResource({
params: () => {
const p = this.post()
return p ? { id: p.id, category: p.category?.slug ?? null } : undefined
},
stream: ({ params }) =>
this.api
.get<Paginated<BlogPostSummaryDto>>('/blog/posts', { query: { category: params.category, limit: RELATED_COUNT + 1 } })
.pipe(
map((page) => page.items.filter((item) => item.id !== params.id).slice(0, RELATED_COUNT)),
catchError(() => of([] as BlogPostSummaryDto[])),
),
})
protected readonly related = computed(() => (this.relatedRes.hasValue() ? this.relatedRes.value() : []))
protected readonly date = formatDate
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
const setStatus = injectResponseStatus()
effect(() => {
if (this.notFound()) {
setStatus(404)
seo.setPage({ title: 'Articolo non trovato', description: 'L’articolo richiesto non esiste o non è più disponibile.', noindex: true })
return
}
if (this.failed()) {
// API giù: 503 (non 404), così l'URL non viene tolto dall'indice né messo in cache
setStatus(503)
seo.setPage({ title: 'Blog', description: 'Articolo del blog ITI TECHNOLOGY.', noindex: true })
return
}
const p = this.post()
if (!p) return
const path = `/blog/${encodeURIComponent(p.slug)}`
const description = p.seoDescription ?? p.excerpt ?? plainTextExcerpt(p.contentMd)
seo.setPage({ title: p.seoTitle ?? p.title, description, path, type: 'article', ...(p.coverUrl ? { image: p.coverUrl } : {}) })
seo.setJsonLd('article', blogPostingJsonLd(siteUrl, p, description))
seo.setJsonLd(
'breadcrumb',
breadcrumbJsonLd(siteUrl, [
{ name: 'Blog', path: '/blog' },
...(p.category ? [{ name: p.category.name, path: blogListPath(p.category.slug) }] : []),
{ name: p.title, path },
]),
)
})
}
}
+239
View File
@@ -0,0 +1,239 @@
import type { BlogCategoryDto, BlogPostSummaryDto, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../core/api.service'
import { SITE_CONFIG } from '../core/app-config'
import { BLOG_PAGE_SIZE, BLOG_QUERY_MAX, blogListPath } from '../core/blog'
import { listQueryParams, listUrl, normalizeQuery, parsePage, totalPages } from '../core/listing'
import { injectResponseStatus } from '../core/response-status'
import { SeoService } from '../core/seo.service'
import { breadcrumbJsonLd } from '../core/structured-data'
import { FilterNav } from '../shared/filter-nav'
import { ListSearch } from '../shared/list-search'
import { NotFoundView } from '../shared/not-found-view'
import { PageHeader } from '../shared/page-header'
import { Pagination } from '../shared/pagination'
import { PostCard } from '../shared/post-card'
const DEFAULT_LEAD = 'Guide pratiche, incentivi e novità dal mondo dell’acqua e dell’energia.'
const DEFAULT_DESCRIPTION =
'Guide, novità e consigli su trattamento acqua, fotovoltaico, pompe di calore, incentivi e mobilità elettrica.'
/**
* /blog e /blog/categoria/:categoria, con ricerca (?q=) e paginazione (?pagina=). Tutto lo stato è nell'URL:
* il server renderizza la stessa pagina che vede il browser dopo un refresh.
* Categoria inesistente, pagina non valida o oltre l'ultima → 404 reale.
*/
@Component({
selector: 'app-blog-page',
imports: [RouterLink, PageHeader, PostCard, NotFoundView, ListSearch, Pagination, FilterNav],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (notFound()) {
<app-not-found-view />
} @else {
<app-page-header [heading]="heading()" [lead]="lead()" [crumbs]="crumbs()" />
<div class="container body">
<div class="toolbar">
<app-list-search
[path]="listPath()"
[query]="q()"
[page]="current()"
[maxLength]="queryMax"
inputId="blog-q"
label="Cerca negli articoli"
placeholder="Es. pompa di calore, incentivi"
/>
@if (filters().length > 1) {
<app-filter-nav label="Categorie del blog" [links]="filters()" [queryParams]="filterQuery()" />
}
</div>
<p class="status" role="status">{{ statusText() }}</p>
@if (failed()) {
<p class="notice">Non è stato possibile caricare gli articoli. Riprova tra qualche istante.</p>
} @else if (page(); as data) {
@if (data.items.length) {
<h2 class="visually-hidden">Articoli</h2>
<ul class="grid">
@for (post of data.items; track post.id; let i = $index) {
<li><app-post-card [post]="post" [priority]="i < 3" /></li>
}
</ul>
} @else {
<div class="empty">
@if (q()) {
<p>Nessun articolo trovato per «{{ q() }}»{{ category() ? ' in ' + category()!.name : '' }}.</p>
<p>Prova con parole diverse o <a [routerLink]="listPath()">mostra tutti gli articoli</a>.</p>
} @else {
<p>Non ci sono ancora articoli{{ category() ? ' in questa categoria' : '' }}: torna a trovarci presto.</p>
}
</div>
}
<app-pagination label="Pagine degli articoli" [path]="listPath()" [q]="q()" [current]="current()" [last]="lastPage()" />
}
</div>
}
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.toolbar {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.status {
margin-bottom: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.grid {
display: grid;
gap: var(--space-5);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(2, 1fr);
}
@include mq.up(lg) {
grid-template-columns: repeat(3, 1fr);
}
}
.empty,
.notice {
padding: var(--space-5);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
p:last-child {
margin: 0;
}
}
.notice {
border-left: 4px solid var(--color-danger);
}
`,
})
export default class BlogPage {
/** Parametro di percorso di /blog/categoria/:categoria. */
readonly categoria = input<string>()
/** Query params (?q=, ?pagina=). */
readonly q = input('', { transform: (v: string | undefined) => normalizeQuery(v, BLOG_QUERY_MAX) })
readonly pagina = input<string>()
private readonly api = inject(ApiService)
protected readonly queryMax = BLOG_QUERY_MAX
/** Pagina richiesta; null = parametro non valido (404). */
private readonly requestedPage = computed(() => parsePage(this.pagina()))
protected readonly current = computed(() => this.requestedPage() ?? 1)
private readonly categoriesRes = rxResource({
stream: () => this.api.get<BlogCategoryDto[]>('/blog/categories'),
})
private readonly postsRes = rxResource({
params: () => {
const page = this.requestedPage()
// Pagina non valida: nessuna richiesta, la pagina risponde 404
if (page === null) return undefined
return { category: this.categoria() ?? null, q: this.q(), page }
},
stream: ({ params }) =>
this.api.get<Paginated<BlogPostSummaryDto>>('/blog/posts', {
query: { category: params.category, q: params.q, limit: BLOG_PAGE_SIZE, offset: (params.page - 1) * BLOG_PAGE_SIZE },
}),
})
private readonly categories = computed(() => (this.categoriesRes.hasValue() ? this.categoriesRes.value() : []))
protected readonly category = computed(() => {
const slug = this.categoria()
return slug ? this.categories().find((c) => c.slug === slug) : undefined
})
protected readonly page = computed(() => (this.postsRes.hasValue() ? this.postsRes.value() : undefined))
protected readonly failed = computed(() => this.categoriesRes.status() === 'error' || this.postsRes.status() === 'error')
protected readonly lastPage = computed(() => totalPages(this.page()?.total ?? 0, BLOG_PAGE_SIZE))
protected readonly notFound = computed(() => {
const current = this.requestedPage()
if (current === null) return true
// Categoria inesistente (si sa solo quando le categorie sono arrivate)
if (this.categoria() && this.categoriesRes.hasValue() && !this.category()) return true
// Oltre l'ultima pagina (la pagina 1 esiste sempre, anche vuota)
const page = this.page()
return current > 1 && page !== undefined && current > totalPages(page.total, BLOG_PAGE_SIZE)
})
protected readonly listPath = computed(() => blogListPath(this.categoria()))
protected readonly heading = computed(() => this.category()?.name ?? 'Blog')
protected readonly lead = computed(() => this.category()?.description ?? DEFAULT_LEAD)
protected readonly crumbs = computed(() => {
const c = this.category()
return c ? [{ label: 'Blog', path: '/blog' }, { label: c.name }] : [{ label: 'Blog' }]
})
protected readonly filters = computed(() => [
{ label: 'Tutti', path: '/blog', active: !this.categoria() },
...this.categories().map((c) => ({ label: c.name, path: blogListPath(c.slug), active: c.slug === this.categoria() })),
])
/** Cambiando categoria si conserva la ricerca e si torna alla prima pagina. */
protected readonly filterQuery = computed(() => listQueryParams(this.q(), 1))
protected readonly statusText = computed(() => {
if (this.postsRes.isLoading()) return 'Caricamento degli articoli…'
const page = this.page()
if (!page || this.failed()) return ''
const n = page.total
const count = n === 1 ? '1 articolo' : `${n} articoli`
const where = this.q() ? `${n === 1 ? 'trovato' : 'trovati'} per «${this.q()}»` : ''
const pageInfo = this.lastPage() > 1 ? ` · pagina ${this.current()} di ${this.lastPage()}` : ''
return [count, where].filter(Boolean).join(' ') + pageInfo
})
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
const setStatus = injectResponseStatus()
effect(() => {
if (this.notFound()) {
setStatus(404)
seo.setPage({ title: 'Pagina non trovata', description: 'La pagina richiesta non esiste o è stata spostata.', noindex: true })
return
}
// API giù: 503, così né i crawler né una cache conservano la pagina vuota
if (this.failed()) {
setStatus(503)
seo.setPage({ title: 'Blog', description: DEFAULT_DESCRIPTION, noindex: true })
return
}
const category = this.category()
const slug = this.categoria()
if (slug && !category) return // categorie non ancora arrivate
const current = this.current()
const q = this.q()
const base = category ? `${category.name} - Blog` : 'Blog'
const title = q ? `Ricerca «${q}» - ${base}` : current > 1 ? `${base} - pagina ${current}` : base
const path = blogListPath(slug)
seo.setPage({
title,
description: category?.description ?? (category ? `Articoli su ${category.name}: ${DEFAULT_DESCRIPTION}` : DEFAULT_DESCRIPTION),
path: listUrl(path, '', current),
// I risultati di ricerca non si indicizzano (contenuti duplicati, combinazioni infinite)
noindex: q !== '',
prev: q || current <= 1 ? null : listUrl(path, '', current - 1),
next: q || current >= this.lastPage() ? null : listUrl(path, '', current + 1),
})
if (category) {
seo.setJsonLd('breadcrumb', breadcrumbJsonLd(siteUrl, [{ name: 'Blog', path: '/blog' }, { name: category.name, path }]))
}
})
}
}
+361
View File
@@ -0,0 +1,361 @@
import type { ShopCartItemDto } from '@iti/shared'
import { NgOptimizedImage } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { CartStore } from '../core/cart.store'
import { formatEuro } from '../core/format'
import { SeoService } from '../core/seo.service'
import { MAX_QUANTITY, productPath } from '../core/shop'
import { PageHeader } from '../shared/page-header'
/**
* /carrello (noindex). Il contenuto si carica solo nel browser (CartStore): il server renderizza intestazione e
* stato di caricamento, uguali per tutti. Quantità e rimozione aggiornano righe, totali e badge alla risposta.
*/
@Component({
selector: 'app-cart-page',
imports: [NgOptimizedImage, RouterLink, PageHeader],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header heading="Carrello" [crumbs]="[{ label: 'Carrello' }]" />
<div class="container body">
@if (cart.error(); as message) {
<p class="error" role="alert">{{ message }}</p>
}
@switch (view()) {
@case ('loading') {
<p class="status" role="status">Caricamento del carrello…</p>
}
@case ('error') {
<div class="empty">
<p>Non è stato possibile caricare il carrello.</p>
<p><button type="button" class="btn btn--primary" (click)="cart.reload()">Riprova</button></p>
</div>
}
@case ('empty') {
<div class="empty">
<p>Il carrello è vuoto.</p>
<p><a class="btn btn--primary" routerLink="/shop">Vai allo shop</a></p>
</div>
}
@default {
@if (cart.cart(); as c) {
<div class="layout" [attr.aria-busy]="cart.busy()">
<section aria-labelledby="items-title">
<h2 id="items-title" class="visually-hidden">Prodotti nel carrello</h2>
<ul class="items">
@for (item of c.items; track item.id) {
<li class="item">
<div class="thumb">
@if (item.thumbnail) {
<img [ngSrc]="item.thumbnail" fill alt="" />
}
</div>
<div class="info">
<h3>
@if (item.productHandle) {
<a [routerLink]="path(item.productHandle)">{{ item.productTitle }}</a>
} @else {
{{ item.productTitle }}
}
</h3>
@if (item.variantTitle && item.variantTitle !== 'Standard') {
<p class="variant">{{ item.variantTitle }}</p>
}
<p class="unit">{{ euro(item.unitPrice) }} cad.</p>
</div>
<div class="qty" role="group" [attr.aria-label]="'Quantità di ' + item.productTitle">
<button
type="button"
[disabled]="item.quantity <= 1"
[attr.aria-label]="'Diminuisci la quantità di ' + item.productTitle"
(click)="cart.adjust(item.id, -1)"
>
−
</button>
<input
type="number"
inputmode="numeric"
min="1"
[max]="maxQuantity"
[value]="item.quantity"
[attr.aria-label]="'Quantità di ' + item.productTitle"
(change)="typed(item, $event)"
/>
<button
type="button"
[disabled]="item.quantity >= maxQuantity"
[attr.aria-label]="'Aumenta la quantità di ' + item.productTitle"
(click)="cart.adjust(item.id, 1)"
>
+
</button>
</div>
<p class="line-total">{{ euro(item.total) }}</p>
<button type="button" class="remove" (click)="cart.remove(item.id)">
Rimuovi<span class="visually-hidden"> {{ item.productTitle }}</span>
</button>
</li>
}
</ul>
<p><a routerLink="/shop">← Continua gli acquisti</a></p>
</section>
<aside class="summary" aria-labelledby="summary-title">
<h2 id="summary-title">Riepilogo</h2>
<dl>
<div>
<dt>Prodotti ({{ c.itemCount }})</dt>
<dd>{{ euro(c.totals.items) }}</dd>
</div>
@if (c.totals.discount > 0) {
<div>
<dt>Sconto</dt>
<dd>−{{ euro(c.totals.discount) }}</dd>
</div>
}
<div>
<dt>Spedizione</dt>
<dd>{{ c.shippingMethod ? euro(c.totals.shipping) : 'calcolata al checkout' }}</dd>
</div>
<div class="total">
<dt>Totale</dt>
<dd>{{ euro(c.totals.total) }}</dd>
</div>
<div class="tax">
<dt>di cui IVA</dt>
<dd>{{ euro(c.totals.tax) }}</dd>
</div>
</dl>
<a class="btn btn--accent checkout" routerLink="/checkout">Procedi al checkout</a>
<p class="live visually-hidden" aria-live="polite">Totale {{ euro(c.totals.total) }}</p>
</aside>
</div>
}
}
}
</div>
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.status {
color: var(--color-ink-muted);
}
.error {
padding: var(--space-3) var(--space-4);
border-left: 4px solid var(--color-danger);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
color: var(--color-danger);
}
.empty {
padding: var(--space-6) var(--space-5);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
text-align: center;
p:last-child {
margin: 0;
}
}
.layout {
display: grid;
gap: var(--space-6);
@include mq.up(lg) {
grid-template-columns: 2fr 1fr;
align-items: start;
}
&[aria-busy='true'] .items {
opacity: 0.7;
}
}
.items {
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
.item {
display: grid;
grid-template-columns: 72px 1fr auto;
grid-template-areas:
'thumb info total'
'thumb qty remove';
gap: var(--space-2) var(--space-4);
align-items: center;
padding-block: var(--space-4);
border-bottom: 1px solid var(--color-line);
@include mq.up(md) {
grid-template-columns: 96px 1fr auto auto auto;
grid-template-areas: 'thumb info qty total remove';
}
}
.thumb {
grid-area: thumb;
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--color-surface-alt);
img {
object-fit: contain;
}
}
.info {
grid-area: info;
h3 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-md);
}
p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
}
.qty {
grid-area: qty;
display: inline-flex;
align-items: center;
button {
width: 44px;
height: 44px;
border: 1px solid var(--color-line);
background: var(--color-surface);
font-size: var(--font-size-lg);
cursor: pointer;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
&:disabled {
color: var(--color-ink-muted);
cursor: not-allowed;
}
}
input {
width: 3.5rem;
height: 44px;
border: 1px solid var(--color-line);
border-inline: 0;
text-align: center;
font: inherit;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
}
}
.line-total {
grid-area: total;
margin: 0;
font-weight: var(--font-weight-bold);
text-align: right;
}
.remove {
grid-area: remove;
justify-self: end;
min-height: 44px;
padding-inline: var(--space-2);
border: 0;
background: none;
color: var(--color-danger);
text-decoration: underline;
cursor: pointer;
}
.summary {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
@include mq.up(lg) {
position: sticky;
top: calc(var(--header-height) + var(--space-4));
}
h2 {
font-size: var(--font-size-xl);
}
dl {
margin: 0 0 var(--space-5);
}
dl div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-2);
}
dd {
margin: 0;
text-align: right;
}
.total {
margin-top: var(--space-2);
border-top: 1px solid var(--color-line);
padding-top: var(--space-3);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.tax {
padding-top: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.checkout {
width: 100%;
}
}
`,
})
export default class CartPage {
protected readonly cart = inject(CartStore)
/** Stato della pagina: sul server e finché il carrello non arriva è sempre "loading". */
protected readonly view = computed(() => {
const state = this.cart.state()
if (state === 'idle' || state === 'loading') return 'loading'
if (state === 'error') return 'error'
return this.cart.isEmpty() ? 'empty' : 'items'
})
protected readonly maxQuantity = MAX_QUANTITY
protected readonly euro = formatEuro
protected readonly path = productPath
constructor() {
inject(SeoService).setPage({
title: 'Carrello',
description: 'Il tuo carrello ITI TECHNOLOGY.',
path: '/carrello',
noindex: true,
})
}
protected typed(item: ShopCartItemDto, event: Event): void {
const input = event.target as HTMLInputElement
const value = Math.trunc(Number(input.value))
if (!Number.isFinite(value) || value < 1) {
input.value = String(item.quantity)
return
}
const quantity = Math.min(MAX_QUANTITY, value)
input.value = String(quantity)
if (quantity !== item.quantity) void this.cart.setQuantity(item.id, quantity)
}
}
+367
View File
@@ -0,0 +1,367 @@
<app-page-header heading="Checkout" [crumbs]="[{ label: 'Carrello', path: '/carrello' }, { label: 'Checkout' }]" />
<div class="container body">
@switch (view()) {
@case ('loading') {
<p class="status" role="status">Caricamento del checkout…</p>
}
@case ('error') {
<div class="empty">
<p>Non è stato possibile caricare il carrello.</p>
<p><button type="button" class="btn btn--primary" (click)="retryLoad()">Riprova</button></p>
</div>
}
@case ('empty') {
<div class="empty">
<p>Il carrello è vuoto: non c'è nulla da pagare.</p>
<p><a class="btn btn--primary" routerLink="/shop">Vai allo shop</a></p>
</div>
}
@default {
@if (cart.cart(); as c) {
<div class="layout">
<ol class="steps">
@for (s of steps; track s; let i = $index) {
<li
class="step"
[class.step--current]="step() === s"
[class.step--done]="isDone(s)"
[attr.aria-current]="step() === s ? 'step' : null"
>
<div class="step__head">
<h2 class="step__title" [id]="'step-' + s" tabindex="-1">
<span class="step__num" aria-hidden="true">{{ isDone(s) ? '✓' : i + 1 }}</span>
{{ stepTitles[s] }}
@if (isDone(s)) {
<span class="visually-hidden">(completato)</span>
}
</h2>
@if (isDone(s) && !locked()) {
<button type="button" class="step__edit" (click)="goTo(s)">
Modifica<span class="visually-hidden"> {{ stepTitles[s] }}</span>
</button>
}
</div>
<!-- Riepilogo di un passo completato -->
@if (isDone(s)) {
<div class="step__summary">
@switch (s) {
@case ('contact') {
<p>{{ email() }}</p>
}
@case ('address') {
@if (c.shippingAddress; as a) {
<p>
<strong>Spedizione:</strong>
@for (line of addressLines(a); track $index) {
<br />{{ line }}
}
</p>
}
@if (billingDifferent() && c.billingAddress; as b) {
<p>
<strong>Fatturazione:</strong>
@for (line of addressLines(b); track $index) {
<br />{{ line }}
}
</p>
}
@if (c.invoice; as inv) {
<p><strong>Fattura a:</strong> {{ inv.companyName }}, P.IVA {{ inv.vatNumber }}</p>
}
}
@case ('shipping') {
@if (c.shippingMethod; as m) {
<p>{{ m.name }} — {{ euro(m.amount) }}</p>
}
}
}
</div>
}
<!-- Passo corrente -->
@if (step() === s) {
<div class="step__panel" [id]="'step-' + s + '-panel'">
@if (stepError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
@switch (s) {
@case ('contact') {
<form (submit)="$event.preventDefault(); submitContact()" novalidate>
@if (authenticated()) {
<p>
Stai ordinando come <strong>{{ session.user()?.firstName }} {{ session.user()?.lastName }}</strong>
({{ email() }}). Conferma e riepilogo dell'ordine arriveranno a questo indirizzo.
</p>
} @else {
<app-form-field
[control]="emailControl"
label="Email"
type="email"
autocomplete="email"
hint="Ti invieremo la conferma e gli aggiornamenti dell'ordine."
[submitted]="contactSubmitted()"
/>
<p class="login-hint">
Hai già un account? <a [href]="loginUrl">Accedi</a> per usare i tuoi dati e trovare l'ordine nella tua
area riservata. Puoi anche continuare senza registrarti.
</p>
}
<div class="actions">
<button type="submit" class="btn btn--primary">Continua</button>
</div>
</form>
}
@case ('address') {
<form [formGroup]="addressForm" (ngSubmit)="submitAddress()" novalidate>
<fieldset>
<legend>Indirizzo di spedizione</legend>
<app-address-fields
[group]="addressForm.controls.shipping"
section="shipping"
[submitted]="addressSubmitted()"
/>
</fieldset>
<label class="check">
<input type="checkbox" formControlName="billingDifferent" />
Indirizzo di fatturazione diverso da quello di spedizione
</label>
@if (billingDifferent()) {
<fieldset>
<legend>Indirizzo di fatturazione</legend>
<app-address-fields
[group]="addressForm.controls.billing"
section="billing"
[phoneRequired]="false"
[submitted]="addressSubmitted()"
/>
</fieldset>
}
<label class="check">
<input type="checkbox" formControlName="wantsInvoice" />
Richiedo la fattura intestata a un'azienda o a un professionista
</label>
@if (wantsInvoice()) {
@let inv = addressForm.controls.invoice.controls;
<fieldset>
<legend>Dati per la fattura</legend>
<div class="form-grid">
<app-form-field
class="span-2"
[control]="inv.companyName"
label="Ragione sociale"
autocomplete="billing organization"
[submitted]="addressSubmitted()"
/>
<app-form-field
[control]="inv.vatNumber"
label="Partita IVA"
inputmode="numeric"
[maxlength]="16"
[messages]="{ vat: 'Partita IVA non valida: 11 cifre' }"
[submitted]="addressSubmitted()"
/>
<app-form-field
[control]="inv.fiscalCode"
label="Codice fiscale"
hint="Solo se diverso dalla partita IVA"
[required]="false"
[maxlength]="16"
[messages]="{ pattern: 'Codice fiscale non valido' }"
[submitted]="addressSubmitted()"
/>
<app-form-field
[control]="inv.sdiCode"
label="Codice destinatario SDI"
hint="7 caratteri"
[required]="false"
[maxlength]="7"
[messages]="{ pattern: 'Il codice destinatario è di 7 caratteri' }"
[submitted]="addressSubmitted()"
/>
<app-form-field
[control]="inv.pec"
label="PEC"
type="email"
[required]="false"
[messages]="{ email: 'Indirizzo PEC non valido' }"
[submitted]="addressSubmitted()"
/>
</div>
<p class="hint">Indica il codice destinatario oppure la PEC per ricevere la fattura elettronica.</p>
</fieldset>
}
<fieldset class="consents">
<legend class="visually-hidden">Consensi</legend>
<label class="check">
<input
type="checkbox"
formControlName="acceptPrivacy"
required
[attr.aria-invalid]="privacyError() ? 'true' : null"
[attr.aria-describedby]="privacyError() ? 'privacy-error' : null"
(change)="privacyChanged()"
/>
<span>
Ho letto l'<a routerLink="/privacy" target="_blank">informativa sulla privacy</a> e acconsento al
trattamento dei miei dati per gestire l'ordine (obbligatorio)
</span>
</label>
@if (privacyError()) {
<p class="field-error" id="privacy-error">Il consenso è necessario per completare l'ordine.</p>
}
<label class="check">
<input type="checkbox" formControlName="acceptMarketing" />
<span>Voglio ricevere novità e offerte via email (facoltativo, revocabile in ogni momento)</span>
</label>
</fieldset>
<div class="actions">
<button type="submit" class="btn btn--primary" [disabled]="busy()">
{{ busy() ? 'Salvataggio…' : 'Continua' }}
</button>
</div>
</form>
}
@case ('shipping') {
<form (submit)="$event.preventDefault(); submitShipping()" novalidate>
@if (shippingOptions(); as options) {
@if (options.length === 0) {
<p>Nessuna modalità di spedizione disponibile per questo indirizzo. Contattaci per completare l'ordine.</p>
} @else {
<fieldset>
<legend>Modalità di spedizione</legend>
@for (o of options; track o.id) {
<label class="check option">
<input type="radio" name="shipping" [value]="o.id" [formControl]="shippingControl" />
<span class="option__name">{{ o.name }}</span>
<span class="option__price">{{ o.amount === 0 ? 'Gratuita' : euro(o.amount) }}</span>
</label>
}
</fieldset>
<div class="actions">
<button type="submit" class="btn btn--primary" [disabled]="busy() || !shippingControl.value">
{{ busy() ? 'Salvataggio…' : 'Continua al pagamento' }}
</button>
</div>
}
} @else {
<p class="status" role="status">Caricamento delle modalità di spedizione…</p>
}
</form>
}
@case ('payment') {
@switch (paymentState()) {
@case ('unavailable') {
<p class="alert alert--error" role="alert">
Il pagamento online non è al momento disponibile. <a routerLink="/contatti">Contattaci</a> per completare
l'ordine.
</p>
}
@case ('loading') {
<p class="status" role="status">Preparazione del pagamento sicuro…</p>
}
@case ('completing') {
<p class="status" role="status">Pagamento confermato, registrazione dell'ordine…</p>
}
}
<div class="payment" [class.payment--hidden]="paymentState() === 'unavailable' || paymentState() === 'completing' || paymentState() === 'complete-failed'">
<div #paymentHost class="payment__element"></div>
</div>
@if (paymentError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<div class="actions">
@switch (paymentState()) {
@case ('error') {
<button type="button" class="btn btn--primary" (click)="retryPayment()">Riprova</button>
}
@case ('complete-failed') {
<button type="button" class="btn btn--primary" (click)="completeOrder()">Riprova a registrare l'ordine</button>
}
@default {
@if (paymentState() !== 'unavailable') {
<button
type="button"
class="btn btn--accent pay"
[disabled]="paymentState() !== 'ready'"
(click)="pay()"
>
{{ paymentState() === 'paying' ? 'Pagamento in corso…' : 'Paga ' + euro(c.totals.total) }}
</button>
}
}
}
</div>
<p class="secure">
Pagamento sicuro gestito da Stripe: i dati della carta non passano dai nostri server. Proseguendo
confermi l'ordine con obbligo di pagamento.
</p>
}
}
</div>
}
</li>
}
</ol>
<aside class="summary" aria-labelledby="summary-title">
<h2 id="summary-title">Riepilogo ordine</h2>
<ul class="summary__items">
@for (item of c.items; track item.id) {
<li>
<span>
{{ item.productTitle }}
@if (item.variantTitle && item.variantTitle !== 'Standard') {
<span class="muted">— {{ item.variantTitle }}</span>
}
<span class="muted">× {{ item.quantity }}</span>
</span>
<span>{{ euro(item.total) }}</span>
</li>
}
</ul>
<dl>
<div>
<dt>Prodotti</dt>
<dd>{{ euro(c.totals.items) }}</dd>
</div>
@if (c.totals.discount > 0) {
<div>
<dt>Sconto</dt>
<dd>−{{ euro(c.totals.discount) }}</dd>
</div>
}
<div>
<dt>Spedizione</dt>
<dd>{{ c.shippingMethod ? euro(c.totals.shipping) : 'da scegliere' }}</dd>
</div>
<div class="total">
<dt>Totale</dt>
<dd>{{ euro(c.totals.total) }}</dd>
</div>
<div class="tax">
<dt>di cui IVA</dt>
<dd>{{ euro(c.totals.tax) }}</dd>
</div>
</dl>
@if (!locked()) {
<p class="summary__edit"><a routerLink="/carrello">Modifica il carrello</a></p>
}
</aside>
</div>
}
}
}
</div>
+234
View File
@@ -0,0 +1,234 @@
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.status {
color: var(--color-ink-muted);
}
.empty {
padding: var(--space-6) var(--space-5);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
text-align: center;
p:last-child {
margin: 0;
}
}
.layout {
display: grid;
gap: var(--space-6);
@include mq.up(lg) {
grid-template-columns: 2fr 1fr;
align-items: start;
}
}
// --- Passi ---
.steps {
margin: 0;
padding: 0;
list-style: none;
counter-reset: none;
}
.step {
padding: var(--space-4) var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
& + & {
margin-top: var(--space-3);
}
&--current {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
&:not(.step--current):not(.step--done) .step__title {
color: var(--color-ink-muted);
}
}
.step__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.step__title {
display: flex;
align-items: center;
gap: var(--space-3);
margin: 0;
font-size: var(--font-size-xl);
outline: none;
}
.step__num {
display: inline-grid;
place-items: center;
flex: none;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-surface-alt);
font-size: var(--font-size-md);
.step--current & {
background: var(--color-primary);
color: var(--color-on-primary);
}
.step--done & {
background: var(--color-secondary);
color: var(--color-on-primary);
}
}
.step__edit {
min-height: 44px;
padding-inline: var(--space-2);
border: 0;
background: none;
color: var(--color-primary);
text-decoration: underline;
cursor: pointer;
}
.step__summary {
margin-top: var(--space-2);
padding-left: calc(2rem + var(--space-3));
color: var(--color-ink-muted);
p {
margin: 0 0 var(--space-2);
}
}
.step__panel {
margin-top: var(--space-4);
}
.login-hint,
.hint {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.consents {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-line);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-4);
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.option {
align-items: center;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
& + & {
margin-top: var(--space-2);
}
&:has(input:checked) {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
input {
margin: 0;
}
}
.option__name {
flex: 1;
}
.option__price {
font-weight: var(--font-weight-bold);
}
// --- Pagamento ---
.payment {
min-height: 4rem;
&--hidden {
display: none;
}
}
.pay {
width: 100%;
font-size: var(--font-size-lg);
@include mq.up(md) {
width: auto;
min-width: 16rem;
}
}
.secure {
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
// --- Riepilogo ---
.summary {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
@include mq.up(lg) {
position: sticky;
top: calc(var(--header-height) + var(--space-4));
}
h2 {
font-size: var(--font-size-xl);
}
dl {
margin: 0;
}
dl div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-2);
}
dd {
margin: 0;
text-align: right;
}
.total {
margin-top: var(--space-2);
border-top: 1px solid var(--color-line);
padding-top: var(--space-3);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.tax {
padding-top: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
}
.summary__items {
margin: 0 0 var(--space-3);
padding: 0 0 var(--space-3);
border-bottom: 1px solid var(--color-line);
list-style: none;
li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-1);
}
}
.muted {
color: var(--color-ink-muted);
}
.summary__edit {
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
}
+481
View File
@@ -0,0 +1,481 @@
import type { ShopCartDto, ShopOrderDto, ShopPaymentDto, ShopShippingOptionDto } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
type ElementRef,
inject,
Injector,
signal,
viewChild,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import type { Stripe, StripeElements, StripePaymentElement } from '@stripe/stripe-js'
import { firstValueFrom, merge } from 'rxjs'
import { ApiError, ApiService } from '../core/api.service'
import { SITE_CONFIG } from '../core/app-config'
import { CartStore } from '../core/cart.store'
import {
addressLines,
addressRequestBody,
applyServerErrors,
CHECKOUT_STEPS,
type CheckoutStep,
createAddressStepForm,
fillAddressStepForm,
firstOpenStep,
syncOptionalGroups,
} from '../core/checkout'
import { formatEuro } from '../core/format'
import { orderConfirmedPath, saveLastOrder } from '../core/order-confirmation'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { loadStripeClient, PAYMENT_OK_STATUSES, STRIPE_APPEARANCE } from '../core/stripe'
import { AddressFields } from '../shared/address-fields'
import { FormField } from '../shared/form-field'
import { PageHeader } from '../shared/page-header'
type PaymentState = 'idle' | 'unavailable' | 'loading' | 'ready' | 'paying' | 'completing' | 'complete-failed' | 'error'
const STEP_TITLES: Record<CheckoutStep, string> = {
contact: 'Contatti',
address: 'Indirizzo',
shipping: 'Spedizione',
payment: 'Pagamento',
}
/**
* /checkout (noindex). Tutto avviene nel browser: il server renderizza solo intestazione e "Caricamento…".
*
* Passi: contatti (email del guest, oppure account) → indirizzo (spedizione, fatturazione, dati per la fattura,
* consensi) → spedizione → pagamento con lo Stripe Payment Element e riepilogo sempre visibile. Dopo la conferma
* di Stripe chiama `POST /shop/cart/complete` e va a /ordine/<numero>/confermato.
*
* - 3D Secure: lo gestisce `confirmPayment` (finestra di autenticazione) con `redirect: 'if_required'`.
* I metodi che richiedono un redirect tornano su /checkout con `payment_intent_client_secret`: la pagina
* rilegge il PaymentIntent e completa l'ordine.
* - Carta rifiutata: messaggio di Stripe sotto il modulo; carrello e PaymentIntent restano utilizzabili.
*/
@Component({
selector: 'app-checkout-page',
imports: [ReactiveFormsModule, RouterLink, PageHeader, FormField, AddressFields],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './checkout.html',
styleUrl: './checkout.scss',
})
export default class CheckoutPage {
protected readonly cart = inject(CartStore)
protected readonly session = inject(SessionService)
private readonly api = inject(ApiService)
private readonly config = inject(SITE_CONFIG)
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
private readonly injector = inject(Injector)
private readonly document = inject(DOCUMENT)
protected readonly euro = formatEuro
protected readonly addressLines = addressLines
protected readonly steps = CHECKOUT_STEPS
protected readonly stepTitles = STEP_TITLES
protected readonly view = signal<'loading' | 'empty' | 'error' | 'ready'>('loading')
protected readonly step = signal<CheckoutStep>('contact')
protected readonly busy = signal(false)
/** Errore generale del passo corrente (quelli dei campi stanno sui campi). */
protected readonly stepError = signal<string | null>(null)
// --- Contatti ---
protected readonly emailControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email, Validators.maxLength(254)],
})
protected readonly contactSubmitted = signal(false)
private readonly guestEmail = signal<string | null>(null)
protected readonly authenticated = computed(() => this.session.status() === 'authenticated')
protected readonly email = computed(() =>
this.authenticated() ? (this.session.user()?.email ?? null) : (this.guestEmail() ?? this.cart.cart()?.email ?? null),
)
protected readonly loginUrl = `${this.config.privateAppUrl}/login?redirect=${encodeURIComponent(`${this.config.siteUrl}/checkout`)}`
// --- Indirizzo ---
protected readonly addressForm = createAddressStepForm()
protected readonly addressSubmitted = signal(false)
protected readonly billingDifferent = toSignal(this.addressForm.controls.billingDifferent.valueChanges, {
initialValue: false,
})
protected readonly wantsInvoice = toSignal(this.addressForm.controls.wantsInvoice.valueChanges, { initialValue: false })
protected readonly privacyError = signal(false)
// --- Spedizione ---
protected readonly shippingOptions = signal<ShopShippingOptionDto[] | null>(null)
protected readonly shippingControl = new FormControl('', { nonNullable: true, validators: [Validators.required] })
// --- Pagamento ---
protected readonly paymentState = signal<PaymentState>('idle')
protected readonly paymentError = signal<string | null>(null)
private readonly paymentHost = viewChild<ElementRef<HTMLElement>>('paymentHost')
private stripe: Stripe | null = null
private elements: StripeElements | null = null
private paymentElement: StripePaymentElement | null = null
/** Incrementato a ogni preparazione: una preparazione superata da un'altra (cambio di passo) si ferma. */
private paymentRun = 0
/** Durante pagamento e completamento non si torna ai passi precedenti. */
protected readonly locked = computed(() => ['paying', 'completing', 'complete-failed'].includes(this.paymentState()))
constructor() {
inject(SeoService).setPage({
title: 'Checkout',
description: 'Completa il tuo ordine ITI TECHNOLOGY: indirizzo, spedizione e pagamento sicuro.',
path: '/checkout',
noindex: true,
})
afterNextRender(() => void this.init())
inject(DestroyRef).onDestroy(() => this.destroyPaymentElement())
// Fatturazione diversa e fattura: i gruppi si validano solo se la relativa casella è spuntata
merge(this.addressForm.controls.billingDifferent.valueChanges, this.addressForm.controls.wantsInvoice.valueChanges)
.pipe(takeUntilDestroyed())
.subscribe(() => syncOptionalGroups(this.addressForm))
}
// --- Avvio ---
private async init(): Promise<void> {
await this.session.ready()
const loaded = await this.cart.reload()
const cart = this.cart.cart()
const params = this.route.snapshot.queryParamMap
const returnSecret = params.get('payment_intent_client_secret')
if (!loaded && this.cart.state() === 'error') {
this.view.set('error')
return
}
if (!cart || cart.items.length === 0) {
this.view.set('empty')
return
}
if (!this.authenticated() && cart.email) {
this.guestEmail.set(cart.email)
this.emailControl.setValue(cart.email)
}
fillAddressStepForm(this.addressForm, cart)
this.view.set('ready')
if (returnSecret) {
await this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true })
await this.resumeAfterRedirect(returnSecret)
return
}
this.goTo(firstOpenStep(cart, this.email() !== null), false)
}
protected async retryLoad(): Promise<void> {
this.view.set('loading')
await this.init()
}
// --- Navigazione tra i passi ---
/** Si avanza solo dopo aver completato un passo: quelli precedenti al corrente sono fatti. */
protected isDone(step: CheckoutStep): boolean {
return this.stepIndex(step) < this.stepIndex(this.step())
}
protected stepIndex(step: CheckoutStep): number {
return this.steps.indexOf(step)
}
protected goTo(step: CheckoutStep, focus = true): void {
if (this.locked()) return
this.stepError.set(null)
if (step !== 'payment') this.destroyPaymentElement()
this.step.set(step)
if (step === 'shipping') void this.loadShippingOptions()
if (step === 'payment') void this.preparePayment()
if (focus) this.focusStep(step)
}
private focusStep(step: CheckoutStep): void {
afterNextRender(() => this.document.getElementById(`step-${step}`)?.focus(), { injector: this.injector })
}
private nextRender(): Promise<void> {
return new Promise((resolve) => afterNextRender(() => resolve(), { injector: this.injector }))
}
/** Errori comuni a tutti i passi: carrello sparito, servizio giù. */
private handleCommonError(err: unknown): void {
const error = err instanceof ApiError ? err : new ApiError(0, 'UNKNOWN_ERROR', 'Si è verificato un errore imprevisto')
if (error.code === 'CART_NOT_FOUND') {
void this.cart.clear()
this.view.set('empty')
return
}
this.stepError.set(error.message)
}
// --- 1. Contatti ---
protected submitContact(): void {
this.contactSubmitted.set(true)
if (!this.authenticated()) {
if (this.emailControl.invalid) {
this.focusFirstInvalid('step-contact')
return
}
this.guestEmail.set(this.emailControl.value.trim().toLowerCase())
}
this.goTo('address')
}
// --- 2. Indirizzo ---
protected async submitAddress(): Promise<void> {
this.addressSubmitted.set(true)
syncOptionalGroups(this.addressForm)
this.privacyError.set(!this.addressForm.controls.acceptPrivacy.value)
if (this.addressForm.invalid) {
this.focusFirstInvalid('step-address')
return
}
const email = this.authenticated() ? null : this.guestEmail()
this.busy.set(true)
this.stepError.set(null)
try {
const cart = await firstValueFrom(
this.api.post<ShopCartDto>('/shop/cart/address', addressRequestBody(this.addressForm, email), this.options),
)
await this.cart.replace(cart)
this.goTo('shipping')
} catch (err) {
if (err instanceof ApiError && (err.code === 'EMAIL_REQUIRED' || err.details.some((d) => d.path === 'body.email'))) {
this.emailControl.setErrors({ server: err.details[0]?.message ?? err.message })
this.goTo('contact')
return
}
if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
const other = applyServerErrors(this.addressForm, err)
this.stepError.set(other.length > 0 ? other.join(' ') : 'Controlla i campi evidenziati.')
this.focusFirstInvalid('step-address')
return
}
this.handleCommonError(err)
} finally {
this.busy.set(false)
}
}
protected privacyChanged(): void {
if (this.addressSubmitted()) this.privacyError.set(!this.addressForm.controls.acceptPrivacy.value)
}
// --- 3. Spedizione ---
private async loadShippingOptions(): Promise<void> {
this.shippingOptions.set(null)
try {
const options = await firstValueFrom(
this.api.get<ShopShippingOptionDto[]>('/shop/cart/shipping-options', this.options),
)
this.shippingOptions.set(options)
const current = this.cart.cart()?.shippingMethod?.optionId
const selected = options.find((o) => o.id === current) ?? options[0]
this.shippingControl.setValue(selected?.id ?? '')
} catch (err) {
this.shippingOptions.set([])
this.handleCommonError(err)
}
}
protected async submitShipping(): Promise<void> {
const optionId = this.shippingControl.value
if (!optionId) return
this.busy.set(true)
this.stepError.set(null)
try {
const cart = await firstValueFrom(this.api.post<ShopCartDto>('/shop/cart/shipping', { optionId }, this.options))
await this.cart.replace(cart)
this.goTo('payment')
} catch (err) {
this.handleCommonError(err)
} finally {
this.busy.set(false)
}
}
// --- 4. Pagamento ---
/**
* Crea (o ricrea, con l'importo aggiornato) la payment session Stripe e monta il Payment Element.
* Si ripete a ogni ingresso nel passo, perché indirizzo o spedizione possono essere cambiati.
*/
private async preparePayment(): Promise<void> {
// Prima si annulla l'eventuale preparazione precedente (destroy incrementa paymentRun), poi parte questa
this.destroyPaymentElement()
const run = ++this.paymentRun
this.paymentError.set(null)
const key = this.config.stripePublishableKey
if (!key) {
this.paymentState.set('unavailable')
return
}
this.paymentState.set('loading')
try {
const [payment, stripe] = await Promise.all([
firstValueFrom(this.api.post<ShopPaymentDto>('/shop/cart/payment', null, this.options)),
loadStripeClient(key),
])
if (run !== this.paymentRun) return
if (!stripe) {
this.paymentState.set('error')
this.paymentError.set('Non è stato possibile caricare il modulo di pagamento. Controlla la connessione e riprova.')
return
}
this.stripe = stripe
this.elements = stripe.elements({ clientSecret: payment.clientSecret, appearance: STRIPE_APPEARANCE, locale: 'it' })
await this.nextRender()
const host = this.paymentHost()?.nativeElement
if (run !== this.paymentRun || !host) return
const element = this.elements.create('payment', {
layout: 'tabs',
defaultValues: { billingDetails: this.billingDetails() },
})
element.on('ready', () => {
if (run === this.paymentRun && this.paymentState() === 'loading') this.paymentState.set('ready')
})
element.on('loaderror', () => {
if (run !== this.paymentRun) return
this.paymentState.set('error')
this.paymentError.set('Non è stato possibile caricare il modulo di pagamento. Riprova tra poco.')
})
element.mount(host)
this.paymentElement = element
} catch (err) {
if (run !== this.paymentRun) return
this.paymentState.set('error')
if (err instanceof ApiError && err.code === 'CART_NOT_READY') {
this.paymentState.set('idle')
this.goTo(firstOpenStep(this.cart.cart()!, this.email() !== null))
return
}
if (err instanceof ApiError && err.code === 'CART_NOT_FOUND') {
this.handleCommonError(err)
return
}
this.paymentError.set(
err instanceof ApiError && err.code === 'SHOP_UNAVAILABLE'
? 'Il pagamento non è disponibile in questo momento. Riprova tra qualche minuto.'
: err instanceof ApiError
? err.message
: 'Si è verificato un errore imprevisto.',
)
}
}
protected retryPayment(): void {
void this.preparePayment()
}
private billingDetails() {
const cart = this.cart.cart()
const a = cart?.billingAddress ?? cart?.shippingAddress
return {
email: this.email() ?? '',
name: a ? `${a.firstName} ${a.lastName}` : '',
phone: cart?.shippingAddress?.phone ?? '',
address: a
? { line1: a.address1, line2: a.address2 ?? '', city: a.city, postal_code: a.postalCode, state: a.province ?? '', country: 'IT' }
: { country: 'IT' },
}
}
protected async pay(): Promise<void> {
if (this.paymentState() !== 'ready' || !this.stripe || !this.elements) return
this.paymentState.set('paying')
this.paymentError.set(null)
const { error, paymentIntent } = await this.stripe.confirmPayment({
elements: this.elements,
confirmParams: { return_url: `${this.document.location.origin}/checkout` },
redirect: 'if_required',
})
if (error) {
this.paymentState.set('ready')
// card_error (es. carta rifiutata) e validation_error hanno un messaggio per l'utente, già in italiano
this.paymentError.set(
error.type === 'card_error' || error.type === 'validation_error'
? (error.message ?? 'Pagamento non riuscito.')
: 'Pagamento non riuscito. Riprova o usa un altro metodo di pagamento.',
)
return
}
if (paymentIntent && PAYMENT_OK_STATUSES.includes(paymentIntent.status)) {
await this.completeOrder()
return
}
this.paymentState.set('ready')
this.paymentError.set('Il pagamento non è stato completato. Riprova o usa un altro metodo di pagamento.')
}
/** Ritorno da un metodo di pagamento con redirect: rilegge il PaymentIntent e completa l'ordine. */
private async resumeAfterRedirect(clientSecret: string): Promise<void> {
this.step.set('payment')
this.paymentState.set('completing')
const stripe = this.config.stripePublishableKey ? await loadStripeClient(this.config.stripePublishableKey) : null
const result = stripe ? await stripe.retrievePaymentIntent(clientSecret) : null
const status = result?.paymentIntent?.status
if (status && PAYMENT_OK_STATUSES.includes(status)) {
await this.completeOrder()
return
}
this.paymentState.set('idle')
await this.preparePayment()
this.paymentError.set('Il pagamento non è stato completato o è stato annullato. Puoi riprovare.')
}
/** Crea l'ordine dopo la conferma di Stripe. Se fallisce si può riprovare: il pagamento non viene ripetuto. */
protected async completeOrder(): Promise<void> {
this.paymentState.set('completing')
this.paymentError.set(null)
try {
const order = await firstValueFrom(this.api.post<ShopOrderDto>('/shop/cart/complete', null, this.options))
saveLastOrder({ order, guest: !this.authenticated() })
this.destroyPaymentElement()
await this.cart.clear()
await this.router.navigateByUrl(orderConfirmedPath(order.number), { replaceUrl: true })
} catch (err) {
this.paymentState.set('complete-failed')
this.paymentError.set(
err instanceof ApiError && err.code === 'CART_NOT_FOUND'
? "L'ordine potrebbe essere già stato registrato: controlla la tua email prima di riprovare."
: 'Il pagamento è stato ricevuto ma non siamo riusciti a registrare l\'ordine. Riprova: non ti verrà addebitato di nuovo.',
)
}
}
private destroyPaymentElement(): void {
this.paymentRun++
this.paymentElement?.destroy()
this.paymentElement = null
this.elements = null
if (!this.locked()) this.paymentState.set('idle')
}
// --- Utilità ---
private readonly options = { withCredentials: true, auth: true }
private focusFirstInvalid(containerId: string): void {
afterNextRender(
() => {
const el = this.document.querySelector<HTMLElement>(`#${containerId}-panel [aria-invalid="true"]`)
el?.focus()
},
{ injector: this.injector },
)
}
}
+139
View File
@@ -0,0 +1,139 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { COMPANY_PAGES, companyPagePath, findCompanyPage } from '../../content/company-pages'
import { SITE_CONFIG } from '../core/app-config'
import { SeoService } from '../core/seo.service'
import { breadcrumbJsonLd } from '../core/structured-data'
import { PageHeader } from '../shared/page-header'
import { PlaceholderBadge } from '../shared/placeholder-badge'
/**
* /azienda e /azienda/:pagina, generate da src/content/company/<slug>.ts. /azienda mostra la prima pagina
* (Chi siamo); gli slug sconosciuti non arrivano qui (canMatch → 404).
*/
@Component({
selector: 'app-company-page',
imports: [RouterLink, PageHeader, PlaceholderBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@let p = page();
<app-page-header [heading]="p.title" [lead]="p.lead" [crumbs]="crumbs()">
<app-placeholder-badge badge [show]="p.placeholder" />
</app-page-header>
<div class="container layout">
<nav class="subnav" aria-label="Azienda">
<ul>
@for (item of pages; track item.slug) {
<li>
<a [routerLink]="path(item)" [attr.aria-current]="item === p ? 'page' : null">{{ item.title }}</a>
</li>
}
</ul>
</nav>
<article>
@for (section of p.sections; track section.heading) {
<section>
<h2>{{ section.heading }}</h2>
@for (paragraph of section.paragraphs; track $index) {
<p>{{ paragraph }}</p>
}
@if (section.items?.length) {
<ul class="items">
@for (item of section.items; track $index) {
<li>{{ item }}</li>
}
</ul>
}
</section>
}
@if (p.cta; as cta) {
<a class="btn btn--primary" [routerLink]="cta.path" [queryParams]="cta.query ?? null">{{ cta.label }}</a>
}
</article>
</div>
`,
styles: `
@use 'mixins' as mq;
.layout {
display: grid;
gap: var(--space-6);
padding-block: var(--space-6) var(--space-8);
@include mq.up(lg) {
grid-template-columns: 220px 1fr;
gap: var(--space-8);
}
}
.subnav ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(lg) {
flex-direction: column;
position: sticky;
top: calc(var(--header-height) + var(--space-5));
}
}
.subnav a {
display: flex;
align-items: center;
min-height: 44px;
padding-inline: var(--space-3);
border-radius: var(--radius-md);
color: var(--color-ink);
text-decoration: none;
&[aria-current='page'] {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
}
article {
max-width: 70ch;
}
section {
margin-bottom: var(--space-6);
}
h2 {
font-size: var(--font-size-2xl);
}
.items {
padding-left: var(--space-5);
li {
margin-bottom: var(--space-2);
}
}
`,
})
export default class CompanyPage {
/** Parametro di route (assente su /azienda). */
readonly pagina = input<string>()
protected readonly pages = COMPANY_PAGES
protected readonly path = companyPagePath
protected readonly page = computed(() => findCompanyPage(this.pagina() ?? '') ?? COMPANY_PAGES[0]!)
protected readonly crumbs = computed(() =>
this.pagina() ? [{ label: 'Azienda', path: '/azienda' }, { label: this.page().title }] : [{ label: 'Azienda' }],
)
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
effect(() => {
const page = this.page()
const path = companyPagePath(page)
seo.setPage({ title: page.title, description: page.description, path })
seo.setJsonLd(
'breadcrumb',
breadcrumbJsonLd(siteUrl, path === '/azienda' ? [{ name: 'Azienda', path }] : [{ name: 'Azienda', path: '/azienda' }, { name: page.title, path }]),
)
})
}
}
@@ -0,0 +1,71 @@
import { afterNextRender, ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'
import { RouterLink } from '@angular/router'
import { readSubmitted, type SubmittedRequest } from '../core/configurator'
import { SeoService } from '../core/seo.service'
import { PageHeader } from '../shared/page-header'
/**
* /configuratore/grazie (noindex): ringraziamento dopo POST /configurator/submit. Il riepilogo viene dal
* sessionStorage della scheda che ha inviato la richiesta (come la conferma d'ordine); il server e le altre
* schede mostrano solo il messaggio generico.
*/
@Component({
selector: 'app-configurator-thanks-page',
imports: [RouterLink, PageHeader],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header heading="Grazie, richiesta ricevuta!" [crumbs]="[{ label: 'Configuratore', path: '/configuratore' }, { label: 'Richiesta inviata' }]" />
<div class="container body">
@if (sent(); as s) {
<p class="alert alert--success" role="status">
Grazie {{ s.name }}, abbiamo ricevuto la tua richiesta per <strong>{{ s.result.solution.title }}</strong>.
Ti abbiamo inviato un riepilogo a <strong>{{ s.email }}</strong>.
</p>
} @else {
<p class="alert alert--success" role="status">Abbiamo ricevuto la tua richiesta e ti abbiamo inviato un riepilogo via email.</p>
}
<h2>E adesso?</h2>
<ol class="next">
<li>Un nostro tecnico verifica la stima con le informazioni che ci hai dato.</li>
<li>Ti contattiamo entro pochi giorni lavorativi per chiarire i dettagli ed eventualmente fissare un sopralluogo.</li>
<li>Ricevi un preventivo definitivo, gratuito e senza impegno.</li>
</ol>
<p class="links">
<a class="btn btn--primary" routerLink="/">Torna alla home</a>
<a class="btn btn--outline" routerLink="/shop">Visita lo shop</a>
<a class="btn btn--outline" routerLink="/configuratore">Nuova configurazione</a>
</p>
</div>
`,
styles: `
.body {
max-width: 52rem;
padding-block: var(--space-5) var(--space-8);
}
h2 {
font-size: var(--font-size-xl);
}
.next li + li {
margin-top: var(--space-2);
}
.links {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-5);
}
`,
})
export default class ConfiguratorThanksPage {
protected readonly sent = signal<SubmittedRequest | null>(null)
constructor() {
inject(SeoService).setPage({
title: 'Richiesta inviata',
description: 'Abbiamo ricevuto la tua richiesta di preventivo dal configuratore: un tecnico ITI TECHNOLOGY ti ricontatterà a breve.',
path: '/configuratore/grazie',
noindex: true,
})
afterNextRender(() => this.sent.set(readSubmitted()))
}
}
+377
View File
@@ -0,0 +1,377 @@
<app-page-header
heading="Configuratore"
lead="Poche domande per trovare la soluzione adatta a te, con una stima indicativa di costi e risparmio. Gratis e senza impegno."
[crumbs]="[{ label: 'Configuratore' }]"
/>
<div class="container body">
@switch (view()) {
@case ('loading') {
<p class="status" role="status">Caricamento del configuratore…</p>
}
@case ('error') {
<div class="empty">
<p>Non è stato possibile caricare il configuratore.</p>
<p><button type="button" class="btn btn--primary" (click)="retryLoad()">Riprova</button></p>
</div>
}
@default {
<div class="wizard">
<!-- Avanzamento -->
<div class="progress">
<p class="progress__label" id="progress-label">
Passo {{ currentNumber() }} di {{ totalSteps() }}
@if (position() === 'result') {
— Risultato
} @else if (step(); as s) {
— {{ s.title }}
}
</p>
<progress [max]="totalSteps()" [value]="currentNumber()" aria-labelledby="progress-label"></progress>
</div>
@if (position() !== 'result') {
@if (step(); as s) {
<form class="panel" (submit)="$event.preventDefault(); next()" novalidate>
<h2 id="step-title" tabindex="-1">{{ s.title }}</h2>
@if (s.description) {
<p class="muted">{{ s.description }}</p>
}
@if (formError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
@for (q of questions(); track q.id) {
@let error = errors()[q.id];
@switch (q.type) {
@case ('single') {
<fieldset class="question" [class.question--invalid]="error">
<legend>
{{ q.label }}
@if (!q.required) {
<span class="optional">(facoltativa)</span>
}
</legend>
@if (q.help) {
<p class="hint" [id]="'q-' + q.id + '-help'">{{ q.help }}</p>
}
<div class="options">
@for (o of q.options; track o.value; let first = $first) {
<label class="option" [class.option--checked]="isChecked(q.id, o.value)">
<input
type="radio"
[name]="'q-' + q.id"
[value]="o.value"
[checked]="isChecked(q.id, o.value)"
[attr.data-question]="first ? q.id : null"
[attr.aria-invalid]="error ? 'true' : null"
[attr.aria-describedby]="describedBy(q)"
(change)="setValue(q, o.value)"
/>
<span class="option__text">
<span class="option__label">{{ o.label }}</span>
@if (o.help) {
<span class="option__help">{{ o.help }}</span>
}
</span>
</label>
}
</div>
@if (error) {
<p class="field-error" [id]="'q-' + q.id + '-error'">{{ error }}</p>
}
</fieldset>
}
@case ('multi') {
<fieldset class="question" [class.question--invalid]="error">
<legend>
{{ q.label }}
<span class="optional">(una o più risposte)</span>
</legend>
@if (q.help) {
<p class="hint" [id]="'q-' + q.id + '-help'">{{ q.help }}</p>
}
<div class="options">
@for (o of q.options; track o.value; let first = $first) {
<label class="option" [class.option--checked]="isChecked(q.id, o.value)">
<input
#box
type="checkbox"
[value]="o.value"
[checked]="isChecked(q.id, o.value)"
[attr.data-question]="first ? q.id : null"
[attr.aria-invalid]="error ? 'true' : null"
[attr.aria-describedby]="describedBy(q)"
(change)="toggle(q, o.value, box.checked)"
/>
<span class="option__text">
<span class="option__label">{{ o.label }}</span>
@if (o.help) {
<span class="option__help">{{ o.help }}</span>
}
</span>
</label>
}
</div>
@if (error) {
<p class="field-error" [id]="'q-' + q.id + '-error'">{{ error }}</p>
}
</fieldset>
}
@case ('number') {
<div class="question form-field">
<label [for]="'q-' + q.id">
{{ q.label }}
@if (!q.required) {
<span class="optional">(facoltativa)</span>
}
</label>
<p class="hint" [id]="'q-' + q.id + '-help'">
@if (q.help) {
{{ q.help }}.
}
Da {{ grouped.format(q.min) }} a {{ grouped.format(q.max) }} {{ q.unit }}.
</p>
<div class="number">
<input
#num
type="text"
[id]="'q-' + q.id"
[attr.inputmode]="q.step >= 1 && q.min >= 0 ? 'numeric' : 'decimal'"
autocomplete="off"
maxlength="12"
[value]="valueOf(q.id)"
[attr.data-question]="q.id"
[attr.required]="q.required ? '' : null"
[attr.aria-invalid]="error ? 'true' : null"
[attr.aria-describedby]="describedBy(q)"
(input)="setValue(q, num.value)"
/>
<span class="number__unit">{{ q.unit }}</span>
</div>
@if (error) {
<p class="error" [id]="'q-' + q.id + '-error'">{{ error }}</p>
}
</div>
}
@case ('province') {
<div class="question form-field">
<label [for]="'q-' + q.id">
{{ q.label }}
@if (!q.required) {
<span class="optional">(facoltativa)</span>
}
</label>
@if (q.help) {
<p class="hint" [id]="'q-' + q.id + '-help'">{{ q.help }}</p>
}
<select
#sel
[id]="'q-' + q.id"
[attr.data-question]="q.id"
[attr.required]="q.required ? '' : null"
[attr.aria-invalid]="error ? 'true' : null"
[attr.aria-describedby]="describedBy(q)"
(change)="setValue(q, sel.value)"
>
<option value="" [selected]="!valueOf(q.id)">Scegli la provincia</option>
@for (p of definition()!.provinces; track p.code) {
<option [value]="p.code" [selected]="valueOf(q.id) === p.code">{{ p.name }} ({{ p.code }})</option>
}
</select>
@if (error) {
<p class="error" [id]="'q-' + q.id + '-error'">{{ error }}</p>
}
</div>
}
}
}
<div class="actions">
@if (stepIndex() > 0) {
<button type="button" class="btn btn--outline" (click)="back()">Indietro</button>
}
<button type="submit" class="btn btn--primary" [disabled]="busy()">
@if (busy()) {
Calcolo in corso…
} @else if (isLastStep()) {
Vedi il risultato
} @else {
Avanti
}
</button>
</div>
</form>
}
} @else if (result(); as r) {
<!-- Risultato -->
<section class="panel result" aria-labelledby="result-title">
<p class="eyebrow">La soluzione consigliata</p>
<h2 id="result-title" tabindex="-1">{{ r.solution.title }}</h2>
<p>{{ r.solution.description }}</p>
@if (r.size) {
<p class="size">Taglia indicativa: <strong>{{ r.size.label }}</strong></p>
}
@if (r.estimates.investmentCents !== null || r.estimates.annualSavingsCents !== null || r.estimates.paybackYears !== null) {
<dl class="estimates">
@if (r.estimates.investmentCents !== null) {
<div>
<dt>Investimento indicativo</dt>
<dd>{{ euro(r.estimates.investmentCents) }}</dd>
</div>
}
@if (r.estimates.annualSavingsCents !== null) {
<div>
<dt>Risparmio annuo stimato</dt>
<dd>{{ euro(r.estimates.annualSavingsCents) }}</dd>
</div>
}
@if (r.estimates.paybackYears !== null) {
<div>
<dt>Tempo di rientro</dt>
<dd>{{ decimal.format(r.estimates.paybackYears) }} {{ r.estimates.paybackYears === 1 ? 'anno' : 'anni' }}</dd>
</div>
}
</dl>
}
@if (r.warnings.length) {
<div class="alert warnings">
<h3>Da tenere presente</h3>
<ul>
@for (w of r.warnings; track $index) {
<li>{{ w }}</li>
}
</ul>
</div>
}
@if (r.details.length) {
<details class="details">
<summary>Come abbiamo calcolato</summary>
<dl>
@for (d of r.details; track $index) {
<div>
<dt>{{ d.label }}</dt>
<dd>{{ d.value }}</dd>
</div>
}
</dl>
</details>
}
<h3>Incentivi</h3>
<p>{{ r.incentivesNote }}</p>
<p class="disclaimer">{{ r.disclaimer }}</p>
<div class="actions">
<button type="button" class="btn btn--outline" (click)="back()">Modifica le risposte</button>
<button type="button" class="link-button" (click)="restart()">Ricomincia da capo</button>
</div>
</section>
@if (r.products.length) {
<app-related-products [handles]="productHandles(r)" heading="Prodotti suggeriti" />
}
<!-- Richiesta di preventivo -->
<section class="panel" aria-labelledby="contact-title">
<h2 id="contact-title">Ricevi un preventivo gratuito</h2>
<p class="muted">Un nostro tecnico verifica la stima e ti ricontatta, senza impegno.</p>
@if (submitError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form id="contact-form" [formGroup]="contactForm" (ngSubmit)="submit()" novalidate>
<div class="form-grid">
<app-form-field
[control]="contactForm.controls.name"
label="Nome e cognome"
autocomplete="name"
[maxlength]="120"
[submitted]="contactSubmitted()"
/>
<app-form-field
[control]="contactForm.controls.email"
label="Email"
type="email"
autocomplete="email"
[maxlength]="254"
[submitted]="contactSubmitted()"
/>
<app-form-field
[control]="contactForm.controls.phone"
label="Telefono"
type="tel"
autocomplete="tel"
[maxlength]="30"
[required]="false"
hint="Per fissare più in fretta un sopralluogo."
[messages]="{ pattern: 'Numero di telefono non valido' }"
[submitted]="contactSubmitted()"
/>
<div class="form-field span-2">
<label for="contact-message">Messaggio <span class="optional">(facoltativo)</span></label>
<textarea
id="contact-message"
rows="4"
maxlength="2000"
formControlName="message"
[attr.aria-invalid]="messageError() ? 'true' : null"
[attr.aria-describedby]="messageError() ? 'contact-message-error' : null"
></textarea>
@if (messageError(); as m) {
<p class="error" id="contact-message-error">{{ m }}</p>
}
</div>
</div>
<fieldset class="consents">
<legend class="visually-hidden">Consensi</legend>
<label class="check">
<input
type="checkbox"
formControlName="acceptPrivacy"
required
[attr.aria-invalid]="privacyError() ? 'true' : null"
[attr.aria-describedby]="privacyError() ? 'privacy-error' : null"
(change)="privacyChanged()"
/>
<span>
Ho letto l'<a href="/privacy" target="_blank">informativa sulla privacy</a> e acconsento al trattamento
dei miei dati per ricevere il preventivo (obbligatorio)
</span>
</label>
@if (privacyError()) {
<p class="field-error" id="privacy-error">Il consenso è necessario per inviare la richiesta.</p>
}
<label class="check">
<input type="checkbox" formControlName="acceptMarketing" />
<span>Voglio ricevere novità e offerte via email (facoltativo, revocabile in ogni momento)</span>
</label>
</fieldset>
<div class="actions">
<button type="submit" class="btn btn--primary" [disabled]="busy()">
{{ busy() ? 'Invio in corso…' : 'Invia la richiesta' }}
</button>
</div>
</form>
</section>
} @else {
<p class="status" role="status">Calcolo del risultato…</p>
@if (formError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
<div class="actions">
<button type="button" class="btn btn--outline" (click)="back()">Torna alle domande</button>
</div>
}
}
</div>
}
}
</div>
+302
View File
@@ -0,0 +1,302 @@
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.status,
.muted {
color: var(--color-ink-muted);
}
.empty {
padding: var(--space-6) var(--space-5);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
text-align: center;
p:last-child {
margin: 0;
}
}
.wizard {
max-width: 52rem;
}
// --- Avanzamento ---
.progress {
margin-bottom: var(--space-4);
}
.progress__label {
margin-bottom: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-ink-muted);
}
progress {
display: block;
width: 100%;
height: 0.5rem;
border: 0;
border-radius: var(--radius-pill);
overflow: hidden;
background: var(--color-surface-alt);
color: var(--color-primary);
appearance: none;
&::-webkit-progress-bar {
background: var(--color-surface-alt);
}
&::-webkit-progress-value {
background: var(--color-primary);
border-radius: var(--radius-pill);
transition: width 0.2s ease;
}
&::-moz-progress-bar {
background: var(--color-primary);
border-radius: var(--radius-pill);
}
}
// --- Pannelli ---
.panel {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
& + & {
margin-top: var(--space-5);
}
@include mq.up(md) {
padding: var(--space-6);
}
}
h2 {
font-size: var(--font-size-2xl);
&:focus {
outline: none;
}
&:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 4px;
}
}
// --- Domande ---
.question {
margin: var(--space-5) 0 0;
legend,
> label {
margin-bottom: var(--space-2);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
.optional {
font-weight: var(--font-weight-regular);
color: var(--color-ink-muted);
}
.hint {
margin: 0 0 var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.field-error {
margin-top: var(--space-2);
}
}
.options {
display: grid;
gap: var(--space-2);
@include mq.up(sm) {
grid-template-columns: repeat(2, 1fr);
}
}
.option {
display: flex;
align-items: flex-start;
gap: var(--space-3);
min-height: 44px;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
&--checked {
border-color: var(--color-primary);
background: var(--color-primary-soft);
box-shadow: 0 0 0 1px var(--color-primary);
}
&:has(input:focus-visible) {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
input {
flex: none;
width: 1.25rem;
height: 1.25rem;
margin: 0.15rem 0 0;
accent-color: var(--color-primary);
}
}
.question--invalid .option {
border-color: var(--color-danger);
}
.option__text {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.option__label {
font-weight: var(--font-weight-medium);
}
.option__help {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.number {
display: flex;
align-items: center;
gap: var(--space-2);
input {
width: 100%;
max-width: 12rem;
}
}
.number__unit {
color: var(--color-ink-muted);
}
.form-field select {
max-width: 24rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-5);
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.link-button {
min-height: 44px;
padding: 0 var(--space-2);
border: 0;
background: none;
color: var(--color-primary);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
// --- Risultato ---
.eyebrow {
margin-bottom: var(--space-1);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-secondary);
}
.size {
font-size: var(--font-size-lg);
}
.estimates {
display: grid;
gap: var(--space-3);
margin: var(--space-5) 0;
@include mq.up(sm) {
grid-template-columns: repeat(3, 1fr);
}
div {
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-secondary-soft);
}
dt {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
dd {
margin: var(--space-1) 0 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-secondary);
}
}
.warnings {
color: var(--color-ink);
border-left-color: var(--color-warning);
background: var(--color-accent-soft);
h3 {
margin-bottom: var(--space-2);
font-size: var(--font-size-md);
}
ul {
margin: 0;
padding-left: var(--space-5);
}
}
.details {
margin: var(--space-4) 0;
summary {
min-height: 44px;
display: flex;
align-items: center;
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
cursor: pointer;
}
dl {
margin: var(--space-2) 0 0;
}
div {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2);
padding-block: var(--space-2);
border-bottom: 1px solid var(--color-line);
}
dt {
color: var(--color-ink-muted);
}
dd {
margin: 0;
font-weight: var(--font-weight-semibold);
}
}
h3 {
font-size: var(--font-size-lg);
}
.disclaimer {
margin-top: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.consents {
margin-top: var(--space-4);
}
textarea {
resize: vertical;
}
+429
View File
@@ -0,0 +1,429 @@
import type { ConfiguratorDefinition, ConfiguratorQuestion, ConfiguratorResult } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import { afterNextRender, ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, Injector, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService } from '../core/api.service'
import {
answerErrorsFromApi,
clearWizard,
firstChoiceQuestion,
firstIncompleteStep,
loadWizard,
preselectionQuestion,
saveSubmitted,
saveWizard,
stepErrors,
type WizardErrors,
wizardState,
type WizardValues,
} from '../core/configurator'
import { formatEuro } from '../core/format'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { FormField } from '../shared/form-field'
import { PageHeader } from '../shared/page-header'
import { RelatedProducts } from '../shared/related-products'
/** Posizione nel wizard: id di uno step oppure il risultato. */
const RESULT = 'result'
const PHONE_PATTERN = /^\+?[\d\s./-]{5,30}$/
/**
* /configuratore — wizard a regole. Tutto ciò che il wizard chiede viene da GET /configurator/definition
* (step, domande, opzioni, branching): aggiungere una domanda nell'API la fa comparire qui senza modifiche.
* La logica (visibilità, validazione per step, persistenza) è in core/configurator.ts.
*
* - Il server renderizza intestazione e "Caricamento…"; il wizard parte nel browser, dove c'è il sessionStorage
* con le risposte (refresh a metà → stesso step, stesse risposte).
* - `?percorso=<valore>` (alias `?esigenza=`) preseleziona la prima domanda e parte dallo step successivo.
* - Risultato da POST /configurator/evaluate; il form finale invia a POST /configurator/submit e porta a
* /configuratore/grazie.
* - Accessibilità: ogni step è un form (Invio = Avanti), al cambio step il focus va al titolo, con errori al
* primo campo non valido; errori collegati con aria-describedby.
*/
@Component({
selector: 'app-configurator-page',
imports: [ReactiveFormsModule, PageHeader, FormField, RelatedProducts],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './configurator.html',
styleUrl: './configurator.scss',
})
export default class ConfiguratorPage {
private readonly api = inject(ApiService)
private readonly session = inject(SessionService)
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly injector = inject(Injector)
private readonly document = inject(DOCUMENT)
private readonly destroyRef = inject(DestroyRef)
protected readonly euro = formatEuro
protected readonly decimal = new Intl.NumberFormat('it-IT', { maximumFractionDigits: 1 })
protected readonly grouped = new Intl.NumberFormat('it-IT', { maximumFractionDigits: 2, useGrouping: 'always' })
protected readonly view = signal<'loading' | 'error' | 'ready'>('loading')
protected readonly definition = signal<ConfiguratorDefinition | null>(null)
protected readonly values = signal<WizardValues>({})
protected readonly position = signal<string>('')
protected readonly errors = signal<WizardErrors>({})
/** Errore generale (rete, servizio): quelli delle domande stanno sotto le domande. */
protected readonly formError = signal<string | null>(null)
protected readonly busy = signal(false)
protected readonly result = signal<ConfiguratorResult | null>(null)
protected readonly state = computed(() => {
const def = this.definition()
return def ? wizardState(def, this.values()) : null
})
protected readonly stepIndex = computed(() => this.state()?.steps.findIndex((s) => s.id === this.position()) ?? -1)
protected readonly step = computed(() => this.state()?.steps[this.stepIndex()] ?? null)
protected readonly questions = computed(() => {
const step = this.step()
return step ? (this.state()?.questions[step.id] ?? []) : []
})
protected readonly isLastStep = computed(() => {
const state = this.state()
return state !== null && this.stepIndex() === state.steps.length - 1
})
/**
* Totale dei passi (step visibili + risultato). Finché la prima domanda non ha risposta gli step dipendenti
* sono nascosti: si stima il totale col percorso più lungo, così la barra non torna indietro dopo la scelta.
*/
protected readonly totalSteps = computed(() => {
const def = this.definition()
const state = this.state()
if (!def || !state) return 1
const first = firstChoiceQuestion(def)
if (first && this.values()[first.id] === undefined) {
return 1 + Math.max(...first.options.map((o) => wizardState(def, { ...this.values(), [first.id]: o.value }).steps.length))
}
return state.steps.length + 1
})
protected readonly currentNumber = computed(() => (this.position() === RESULT ? this.totalSteps() : this.stepIndex() + 1))
// --- Form finale ---
protected readonly contactForm = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(120)] }),
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email, Validators.maxLength(254)] }),
phone: new FormControl('', { nonNullable: true, validators: [Validators.pattern(PHONE_PATTERN)] }),
message: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(2000)] }),
acceptPrivacy: new FormControl(false, { nonNullable: true }),
acceptMarketing: new FormControl(false, { nonNullable: true }),
})
protected readonly contactSubmitted = signal(false)
protected readonly privacyError = signal(false)
protected readonly submitError = signal<string | null>(null)
protected readonly messageError = signal<string | null>(null)
constructor() {
inject(SeoService).setPage({
title: 'Configuratore',
description:
'Rispondi a poche domande e ricevi una soluzione indicativa per addolcitore, fotovoltaico o pompa di calore, con stima dei costi e del risparmio.',
path: '/configuratore',
})
afterNextRender(() => void this.init())
// Ogni modifica (risposte o posizione) finisce nel sessionStorage
effect(() => {
const def = this.definition()
const position = this.position()
if (def && position) saveWizard({ version: def.version, values: this.values(), position })
})
}
// --- Avvio ---
private async init(): Promise<void> {
this.view.set('loading')
let def: ConfiguratorDefinition
try {
def = await firstValueFrom(this.api.get<ConfiguratorDefinition>('/configurator/definition'))
} catch {
this.view.set('error')
return
}
const saved = loadWizard()
this.values.set(saved?.values ?? {})
this.definition.set(def)
this.view.set('ready')
// Posizione salvata: valida solo se tutti gli step precedenti sono completi (la definizione può essere cambiata)
const incomplete = firstIncompleteStep(def, this.values())
const state = this.state()!
if (saved?.position === RESULT && incomplete === -1) {
void this.evaluate(false)
} else {
const savedIndex = state.steps.findIndex((s) => s.id === saved?.position)
const limit = incomplete === -1 ? state.steps.length - 1 : incomplete
this.position.set(state.steps[savedIndex >= 0 && savedIndex <= limit ? savedIndex : saved ? limit : 0]!.id)
}
// Percorso preselezionato dall'URL (anche navigando qui da un'altra esigenza con la pagina già aperta)
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const preset = params.get('percorso') ?? params.get('esigenza')
if (preset) this.applyPreset(preset)
})
await this.session.ready()
const user = this.session.user()
if (user) {
const { name, email } = this.contactForm.controls
if (!name.value) name.setValue(`${user.firstName} ${user.lastName}`.trim())
if (!email.value) email.setValue(user.email)
}
}
/** Imposta la prima risposta e va allo step successivo; se coincide con quella salvata riprende da dov'era. */
private applyPreset(value: string): void {
const def = this.definition()
const q = def ? preselectionQuestion(def, value) : null
// Il parametro serve una volta sola: toglierlo evita che un refresh dopo "Ricomincia" lo riapplichi
void this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true })
if (!def || !q || this.values()[q.id] === value) return
this.values.set({ [q.id]: value })
this.result.set(null)
this.errors.set({})
const steps = this.state()!.steps
this.goTo(steps[Math.min(1, steps.length - 1)]!.id, false)
}
protected retryLoad(): void {
void this.init()
}
// --- Risposte ---
protected valueOf(id: string): string {
const v = this.values()[id]
return typeof v === 'string' ? v : ''
}
protected isChecked(id: string, option: string): boolean {
const v = this.values()[id]
return Array.isArray(v) ? v.includes(option) : v === option
}
protected setValue(q: ConfiguratorQuestion, value: string | string[]): void {
this.values.update((values) => ({ ...values, [q.id]: value }))
this.result.set(null)
// Un errore già mostrato si aggiorna mentre si corregge
if (this.errors()[q.id]) this.revalidate(q.id)
}
protected toggle(q: ConfiguratorQuestion, option: string, checked: boolean): void {
const current = this.values()[q.id]
const list = Array.isArray(current) ? current : []
const next = checked ? [...list.filter((v) => v !== option), option] : list.filter((v) => v !== option)
// Ordine della definizione, così le risposte non dipendono dall'ordine dei clic
const order = q.type === 'multi' ? q.options.map((o) => o.value) : []
this.setValue(q, order.filter((v) => next.includes(v)))
}
private revalidate(id: string): void {
const def = this.definition()
const step = this.step()
if (!def || !step) return
const problem = stepErrors(def, this.state()!, step.id, this.values())[id]
this.errors.update((errors) => {
const { [id]: _, ...rest } = errors
return problem ? { ...rest, [id]: problem } : rest
})
}
protected describedBy(q: ConfiguratorQuestion): string | null {
const ids = [q.help || q.type === 'number' ? `q-${q.id}-help` : null, this.errors()[q.id] ? `q-${q.id}-error` : null]
const list = ids.filter(Boolean)
return list.length > 0 ? list.join(' ') : null
}
// --- Navigazione ---
private goTo(position: string, focus = true): void {
this.formError.set(null)
this.position.set(position)
if (focus) this.focus(position === RESULT ? 'result-title' : 'step-title')
}
protected next(): void {
const def = this.definition()
const step = this.step()
if (!def || !step || this.busy()) return
const errors = stepErrors(def, this.state()!, step.id, this.values())
this.errors.set(errors)
const firstInvalid = this.questions().find((q) => errors[q.id])
if (firstInvalid) {
this.focusQuestion(firstInvalid.id)
return
}
if (this.isLastStep()) {
void this.evaluate(true)
return
}
this.goTo(this.state()!.steps[this.stepIndex() + 1]!.id)
}
protected back(): void {
const steps = this.state()?.steps ?? []
if (this.position() === RESULT) {
this.goTo(steps[steps.length - 1]!.id)
return
}
if (this.stepIndex() > 0) {
this.errors.set({})
this.goTo(steps[this.stepIndex() - 1]!.id)
}
}
protected restart(): void {
clearWizard()
this.values.set({})
this.errors.set({})
this.result.set(null)
this.goTo(this.state()!.steps[0]!.id)
}
// --- Risultato ---
private async evaluate(focus: boolean): Promise<void> {
const state = this.state()
if (!state) return
this.busy.set(true)
this.formError.set(null)
try {
const result = await firstValueFrom(
this.api.post<ConfiguratorResult>('/configurator/evaluate', { answers: state.answers }),
)
this.result.set(result)
this.goTo(RESULT, focus)
} catch (err) {
if (!this.position()) this.position.set(state.steps[state.steps.length - 1]!.id)
this.handleAnswerErrors(err)
} finally {
this.busy.set(false)
}
}
/** Errori dell'API sulle risposte → step della prima domanda segnalata; altrimenti messaggio generale. */
private handleAnswerErrors(err: unknown): void {
if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
const errors = answerErrorsFromApi(err.details)
const state = this.state()!
const target = state.steps.find((s) => (state.questions[s.id] ?? []).some((q) => errors[q.id]))
if (target) {
this.errors.set(errors)
this.goTo(target.id, false)
const first = (state.questions[target.id] ?? []).find((q) => errors[q.id])
if (first) this.focusQuestion(first.id)
return
}
}
this.formError.set(errorMessage(err))
}
protected productHandles(result: ConfiguratorResult): string[] {
return result.products.map((p) => p.handle)
}
// --- Invio della richiesta ---
protected privacyChanged(): void {
if (this.contactSubmitted()) this.privacyError.set(!this.contactForm.controls.acceptPrivacy.value)
}
protected async submit(): Promise<void> {
const state = this.state()
const result = this.result()
if (!state || !result || this.busy()) return
this.contactSubmitted.set(true)
this.submitError.set(null)
const form = this.contactForm.getRawValue()
this.privacyError.set(!form.acceptPrivacy)
this.messageError.set(this.contactForm.controls.message.invalid ? 'Massimo 2000 caratteri' : null)
if (this.contactForm.invalid || !form.acceptPrivacy) {
this.focusFirstInvalidContact()
return
}
const phone = form.phone.trim()
const message = form.message.trim()
const body = {
answers: state.answers,
contact: { name: form.name.trim(), email: form.email.trim().toLowerCase(), ...(phone ? { phone } : {}) },
...(message ? { message } : {}),
acceptPrivacy: true,
acceptMarketing: form.acceptMarketing,
}
this.busy.set(true)
try {
const created = await firstValueFrom(
this.api.post<{ id: string; result: ConfiguratorResult }>('/configurator/submit', body, { auth: true }),
)
saveSubmitted({ name: body.contact.name, email: body.contact.email, result: created.result })
clearWizard()
await this.router.navigateByUrl('/configuratore/grazie', { replaceUrl: true })
} catch (err) {
if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
if (Object.keys(answerErrorsFromApi(err.details)).length > 0) {
this.handleAnswerErrors(err)
return
}
const other = this.applyContactErrors(err.details)
this.submitError.set(other.length > 0 ? other.join(' ') : 'Controlla i campi evidenziati.')
this.focusFirstInvalidContact()
return
}
this.submitError.set(
err instanceof ApiError && err.code === 'RATE_LIMITED'
? 'Hai inviato troppe richieste in poco tempo. Riprova tra qualche minuto.'
: errorMessage(err),
)
} finally {
this.busy.set(false)
}
}
/** Errori dell'API sui campi del contatto (`body.contact.email`, `body.message`…); restituisce gli altri. */
private applyContactErrors(details: { path: string; message: string }[]): string[] {
const other: string[] = []
const c = this.contactForm.controls
for (const d of details) {
const field = d.path.replace(/^body\./, '').replace(/^contact\./, '')
if (field === 'name' || field === 'email' || field === 'phone') c[field].setErrors({ server: d.message })
else if (field === 'message') this.messageError.set(d.message)
else if (field === 'acceptPrivacy') this.privacyError.set(true)
else other.push(d.message)
}
return other
}
// --- Focus ---
private focus(id: string): void {
afterNextRender(() => this.document.getElementById(id)?.focus(), { injector: this.injector })
}
private focusQuestion(id: string): void {
afterNextRender(
() => this.document.querySelector<HTMLElement>(`[data-question="${CSS.escape(id)}"]`)?.focus(),
{ injector: this.injector },
)
}
private focusFirstInvalidContact(): void {
afterNextRender(
() => this.document.querySelector<HTMLElement>('#contact-form [aria-invalid="true"]')?.focus(),
{ injector: this.injector },
)
}
}
function errorMessage(err: unknown): string {
if (err instanceof ApiError && err.code === 'NETWORK_ERROR') {
return 'Servizio non raggiungibile. Controlla la connessione e riprova.'
}
if (err instanceof ApiError && err.status < 500) return err.message
return 'Si è verificato un errore. Riprova tra qualche minuto.'
}
+406
View File
@@ -0,0 +1,406 @@
import type { ShopProductDetailDto } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import { afterNextRender, ChangeDetectionStrategy, Component, effect, inject, Injector, input, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { catchError, firstValueFrom, map, of } from 'rxjs'
import { findArea } from '../../content/areas'
import { SOLUTIONS } from '../../content/catalog'
import { ApiError, ApiService } from '../core/api.service'
import { contactSubject } from '../core/contact'
import { FormGuard, type FormTicket } from '../core/form-guard'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { COMPANY } from '../core/site'
import { ConsentFields } from '../shared/consent-fields'
import { FormField } from '../shared/form-field'
import { Honeypot } from '../shared/honeypot'
import { OsmMap } from '../shared/osm-map'
import { PageHeader } from '../shared/page-header'
const PHONE_PATTERN = /^\+?[\d\s./-]{5,30}$/
/**
* /contatti: form verso `POST /contact` (anti-spam: token firmato + honeypot, vedi core/form-guard.ts), dati
* aziendali (solo quelli valorizzati in COMPANY) e mappa OpenStreetMap caricata solo dopo "Mostra mappa".
* L'oggetto si precompila da `?oggetto=…[&soluzione=<slug>][&prodotto=<handle>]` (core/contact.ts).
*/
@Component({
selector: 'app-contact-page',
imports: [ReactiveFormsModule, RouterLink, PageHeader, FormField, ConsentFields, Honeypot, OsmMap],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header heading="Contatti" [lead]="lead" [crumbs]="[{ label: 'Contatti' }]" />
<div class="container layout">
<section class="panel" aria-labelledby="form-title">
<h2 id="form-title" tabindex="-1">Scrivici</h2>
@if (sent()) {
<p class="alert alert--success" role="status">
Grazie, abbiamo ricevuto il tuo messaggio. Ti risponderemo al più presto, di solito entro un giorno lavorativo.
</p>
<p><button type="button" class="btn btn--outline" (click)="newMessage()">Invia un altro messaggio</button></p>
} @else {
<p class="muted">Per informazioni, preventivi o assistenza. I campi senza indicazione sono obbligatori.</p>
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form id="contact-form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="form-grid">
<app-form-field [control]="form.controls.name" label="Nome e cognome" autocomplete="name" [maxlength]="120" [submitted]="submitted()" />
<app-form-field
[control]="form.controls.email"
label="Email"
type="email"
autocomplete="email"
[maxlength]="254"
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.phone"
label="Telefono"
type="tel"
autocomplete="tel"
[maxlength]="30"
[required]="false"
[messages]="{ pattern: 'Numero di telefono non valido' }"
[submitted]="submitted()"
/>
<app-form-field [control]="form.controls.subject" label="Oggetto" [maxlength]="200" [required]="false" [submitted]="submitted()" />
<div class="form-field span-2">
<label for="contact-message">Messaggio</label>
<textarea
id="contact-message"
rows="6"
maxlength="5000"
formControlName="message"
required
[attr.aria-invalid]="messageError() ? 'true' : null"
[attr.aria-describedby]="messageError() ? 'contact-message-error' : null"
(blur)="messageTouched.set(true)"
></textarea>
@if (messageError(); as m) {
<p class="error" id="contact-message-error">{{ m }}</p>
}
</div>
</div>
<app-consent-fields
[privacy]="form.controls.acceptPrivacy"
[marketing]="form.controls.acceptMarketing"
purpose="rispondere alla mia richiesta"
[showError]="submitted() && !form.controls.acceptPrivacy.value"
/>
<app-honeypot [(value)]="honeypot" />
<div class="actions">
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Invio in corso…' : 'Invia il messaggio' }}</button>
</div>
</form>
}
</section>
<aside class="info" aria-labelledby="info-title">
<h2 id="info-title">{{ company.name }}</h2>
<address>
@if (company.legalName) {
<span>{{ company.legalName }}</span>
}
@if (company.address; as a) {
<span>{{ a.street }}<br />{{ a.postalCode }} {{ a.city }} ({{ a.province }})</span>
}
@if (company.phone) {
<span>Telefono: <a [href]="'tel:' + company.phone">{{ company.phone }}</a></span>
}
@if (company.email) {
<span>Email: <a [href]="'mailto:' + company.email">{{ company.email }}</a></span>
}
@if (company.vatNumber) {
<span>P.IVA {{ company.vatNumber }}</span>
}
</address>
<h3>Orari dello showroom</h3>
<ul class="hours">
@for (line of company.openingHoursText; track line) {
<li>{{ line }}</li>
}
</ul>
<p><a routerLink="/showroom">Prenota una visita allo showroom</a></p>
@if (company.geo; as geo) {
<h3>Dove siamo</h3>
<div class="map">
@if (mapVisible()) {
@defer {
<app-osm-map [lat]="geo.lat" [lng]="geo.lng" [label]="company.name" />
} @loading {
<p class="map__placeholder" role="status">Caricamento della mappa…</p>
}
} @else {
<div class="map__placeholder">
<p>La mappa è fornita da OpenStreetMap: viene caricata dai loro server solo se la richiedi.</p>
<button type="button" class="btn btn--outline" (click)="mapVisible.set(true)">Mostra mappa</button>
</div>
}
</div>
<p class="small">
<a [href]="osmUrl(geo.lat, geo.lng)" target="_blank" rel="noopener noreferrer">Apri in OpenStreetMap</a>
</p>
}
</aside>
</div>
`,
styles: `
@use 'mixins' as mq;
.layout {
display: grid;
gap: var(--space-6);
padding-block: var(--space-6) var(--space-8);
@include mq.up(lg) {
grid-template-columns: 3fr 2fr;
align-items: start;
}
}
.panel {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
h2 {
font-size: var(--font-size-2xl);
&:focus {
outline: none;
}
}
h3 {
margin-top: var(--space-5);
font-size: var(--font-size-lg);
}
.muted {
color: var(--color-ink-muted);
}
textarea {
resize: vertical;
}
.actions {
margin-top: var(--space-4);
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
address {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-style: normal;
}
.hours {
padding-left: var(--space-5);
}
.map {
height: 320px;
}
.map__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
margin: 0;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
text-align: center;
p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
}
.small {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
}
`,
})
export default class ContactPage {
/** Parametri di query (withComponentInputBinding). */
readonly oggetto = input<string>()
readonly soluzione = input<string>()
readonly prodotto = input<string>()
private readonly api = inject(ApiService)
private readonly guard = inject(FormGuard)
private readonly session = inject(SessionService)
private readonly document = inject(DOCUMENT)
private readonly injector = inject(Injector)
protected readonly company = COMPANY
protected readonly lead = 'Scrivici per informazioni, preventivi o assistenza: ti rispondiamo al più presto.'
protected readonly form = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(120)] }),
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email, Validators.maxLength(254)] }),
phone: new FormControl('', { nonNullable: true, validators: [Validators.pattern(PHONE_PATTERN)] }),
subject: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }),
message: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(5000)],
}),
acceptPrivacy: new FormControl(false, { nonNullable: true }),
acceptMarketing: new FormControl(false, { nonNullable: true }),
})
protected readonly honeypot = signal('')
protected readonly submitted = signal(false)
protected readonly messageTouched = signal(false)
protected readonly serverMessageError = signal<string | null>(null)
protected readonly busy = signal(false)
protected readonly sent = signal(false)
protected readonly error = signal<string | null>(null)
protected readonly mapVisible = signal(false)
private readonly messageVersion = signal(0)
private ticket: Promise<FormTicket> | null = null
constructor() {
inject(SeoService).setPage({
title: 'Contatti',
description:
'Contatta ITI TECHNOLOGY per informazioni, preventivi e assistenza su trattamento acqua, fotovoltaico, pompe di calore e mobilità elettrica.',
path: '/contatti',
})
this.form.controls.message.valueChanges.subscribe(() => {
this.serverMessageError.set(null)
this.messageVersion.update((v) => v + 1)
})
// Oggetto precompilato dai parametri (solo se l'utente non l'ha già scritto)
effect(() => void this.prefillSubject(this.oggetto(), this.soluzione(), this.prodotto()))
afterNextRender(() => {
this.ticket = this.guard.issue()
this.ticket.catch(() => (this.ticket = null))
void this.prefillFromSession()
})
}
protected messageError(): string | null {
this.messageVersion()
if (this.serverMessageError()) return this.serverMessageError()
const c = this.form.controls.message
if (!c.invalid || !(this.messageTouched() || this.submitted())) return null
if (c.hasError('required')) return 'Campo obbligatorio'
if (c.hasError('minlength')) return 'Scrivi almeno 10 caratteri'
return 'Massimo 5000 caratteri'
}
private async prefillSubject(topic: string | undefined, solution: string | undefined, product: string | undefined) {
let about: string | null = null
if (solution) about = findArea(solution)?.title ?? SOLUTIONS.find((s) => s.slug === solution)?.title ?? null
if (product) {
about = await firstValueFrom(
this.api.get<ShopProductDetailDto>(`/shop/products/${encodeURIComponent(product)}`).pipe(
map((p) => p.title),
catchError(() => of(product)),
),
)
}
const subject = contactSubject(topic, about)
const control = this.form.controls.subject
if (subject && !control.dirty) control.setValue(subject)
}
private async prefillFromSession(): Promise<void> {
await this.session.ready()
const user = this.session.user()
if (!user) return
const { name, email } = this.form.controls
if (!name.value) name.setValue(`${user.firstName} ${user.lastName}`.trim())
if (!email.value) email.setValue(user.email)
}
protected osmUrl(lat: number, lng: number): string {
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`
}
protected async submit(): Promise<void> {
if (this.busy()) return
this.submitted.set(true)
this.error.set(null)
const v = this.form.getRawValue()
if (this.form.invalid || !v.acceptPrivacy) {
this.focusFirstInvalid()
return
}
const phone = v.phone.trim()
const subject = v.subject.trim()
const body = {
name: v.name.trim(),
email: v.email.trim().toLowerCase(),
...(phone ? { phone } : {}),
...(subject ? { subject } : {}),
message: v.message.trim(),
acceptPrivacy: true,
acceptMarketing: v.acceptMarketing,
}
this.busy.set(true)
try {
const ticket = await (this.ticket ??= this.guard.issue())
const { ticket: used } = await this.guard.submit<{ message: string }>('/contact', body, ticket, this.honeypot())
this.ticket = Promise.resolve(used)
this.sent.set(true)
this.focus('form-title')
} catch (err) {
this.ticket = null
if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
const other = this.applyServerErrors(err.details)
this.error.set(other.length > 0 ? other.join(' ') : 'Controlla i campi evidenziati.')
this.focusFirstInvalid()
} else if (err instanceof ApiError && err.code === 'RATE_LIMITED') {
this.error.set('Hai inviato troppi messaggi in poco tempo. Riprova tra qualche minuto.')
} else if (err instanceof ApiError && err.code === 'NETWORK_ERROR') {
this.error.set('Servizio non raggiungibile. Controlla la connessione e riprova.')
} else {
this.error.set(err instanceof ApiError && err.status < 500 ? err.message : 'Si è verificato un errore. Riprova tra qualche minuto.')
}
} finally {
this.busy.set(false)
}
}
protected newMessage(): void {
const { name, email, phone } = this.form.getRawValue()
this.form.reset({ name, email, phone, subject: '', message: '', acceptPrivacy: false, acceptMarketing: false })
this.submitted.set(false)
this.messageTouched.set(false)
this.sent.set(false)
this.ticket = this.guard.issue()
this.focus('form-title')
}
private applyServerErrors(details: { path: string; message: string }[]): string[] {
const other: string[] = []
const c = this.form.controls
for (const d of details) {
const field = d.path.replace(/^body\./, '')
if (field === 'name' || field === 'email' || field === 'phone' || field === 'subject') c[field].setErrors({ server: d.message })
else if (field === 'message') this.serverMessageError.set(d.message)
else other.push(d.message)
}
return other
}
private focus(id: string): void {
afterNextRender(() => this.document.getElementById(id)?.focus(), { injector: this.injector })
}
private focusFirstInvalid(): void {
afterNextRender(() => this.document.querySelector<HTMLElement>('#contact-form [aria-invalid="true"]')?.focus(), {
injector: this.injector,
})
}
}
+92
View File
@@ -0,0 +1,92 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { COOKIE_POLICY, COOKIE_TABLE } from '../../content/legal'
import { ConsentService } from '../core/consent.service'
import { formatDate } from '../core/format'
import { SeoService } from '../core/seo.service'
import { LegalPage } from '../shared/legal-page'
/** /cookie: cookie policy da src/content/legal.ts, tabella dei cookie, scelta attuale e pulsante per modificarla. */
@Component({
selector: 'app-cookie-page',
imports: [LegalPage],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-legal-page [page]="page">
<h2>Elenco dei cookie</h2>
<div class="table-wrap">
<table>
<caption class="visually-hidden">Cookie e memorizzazione locale usati dal sito</caption>
<thead>
<tr>
<th scope="col">Nome</th>
<th scope="col">Fornitore</th>
<th scope="col">Finalità</th>
<th scope="col">Durata</th>
<th scope="col">Categoria</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.name) {
<tr>
<td><code>{{ row.name }}</code></td>
<td>{{ row.provider }}</td>
<td>{{ row.purpose }}</td>
<td>{{ row.duration }}</td>
<td>{{ row.category }}</td>
</tr>
}
</tbody>
</table>
</div>
<h2>La tua scelta</h2>
<p role="status">{{ current() }}</p>
<button type="button" class="btn btn--primary" (click)="consent.openPreferences()">Modifica le preferenze cookie</button>
</app-legal-page>
`,
styles: `
h2 {
margin-top: var(--space-6);
font-size: var(--font-size-xl);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
th,
td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-line);
text-align: left;
vertical-align: top;
}
th {
background: var(--color-surface-alt);
}
code {
font-size: var(--font-size-xs);
word-break: break-word;
}
`,
})
export default class CookiePolicyPage {
protected readonly consent = inject(ConsentService)
protected readonly page = COOKIE_POLICY
protected readonly rows = COOKIE_TABLE
protected readonly current = computed(() => {
const c = this.consent.choice()
if (!c) return 'Non hai ancora espresso una scelta: sono attivi solo i cookie necessari.'
const on = ['necessari', c.statistics ? 'statistiche' : null, c.marketing ? 'marketing' : null].filter(Boolean)
const what = on.length === 1 ? 'solo necessari' : `${on.slice(0, -1).join(', ')} e ${on[on.length - 1]}`
return `Cookie consentiti: ${what} (scelta del ${formatDate(c.decidedAt.toISOString())}).`
})
constructor() {
inject(SeoService).setPage({ title: this.page.title, description: this.page.description, path: '/cookie' })
}
}
+158
View File
@@ -0,0 +1,158 @@
<!-- 1. Hero -->
<section class="hero">
<div class="container">
<app-placeholder-badge [show]="home.placeholder" />
<h1>{{ home.hero.title }}</h1>
<p class="payoff">{{ home.hero.payoff }}</p>
<div class="actions">
<a class="btn btn--accent" routerLink="/configuratore">Trova la soluzione giusta</a>
<a class="btn btn--outline" routerLink="/contatti" [queryParams]="{ oggetto: 'preventivo' }">Richiedi preventivo</a>
</div>
</div>
</section>
<!-- 2. Settori: Acqua in evidenza -->
<section class="container block" aria-labelledby="sectors-title">
<h2 id="sectors-title">{{ home.sectors.title }}</h2>
<p class="lead">{{ home.sectors.lead }}</p>
<ul class="sectors">
<li class="sector sector--featured" [attr.data-area]="featuredArea.slug">
<img [ngSrc]="featuredArea.image.src" width="600" height="400" alt="" class="sector__img" />
<div>
<h3><a [routerLink]="['/soluzioni', featuredArea.slug]">{{ featuredArea.title }}</a></h3>
<p>{{ featuredArea.tagline }}</p>
<a class="btn btn--primary" [routerLink]="['/soluzioni', featuredArea.slug]">Scopri le soluzioni per l'acqua</a>
</div>
</li>
@for (area of otherAreas; track area.slug) {
<li class="sector" [attr.data-area]="area.slug">
<h3><a [routerLink]="['/soluzioni', area.slug]">{{ area.title }}</a></h3>
<p>{{ area.tagline }}</p>
</li>
}
</ul>
</section>
<!-- 3. Energia rinnovabile -->
<section class="energy" aria-labelledby="energy-title">
<div class="container energy__grid">
<div>
<h2 id="energy-title">{{ home.energy.title }}</h2>
<p class="lead">{{ home.energy.text }}</p>
<ul class="checks">
@for (point of home.energy.points; track point) {
<li>{{ point }}</li>
}
</ul>
<a class="btn btn--primary" routerLink="/soluzioni/energia">Fotovoltaico e accumulo</a>
</div>
@if (energy) {
<img [ngSrc]="energy.image.src" [width]="energy.image.width" [height]="energy.image.height" [alt]="energy.image.alt" class="energy__img" />
}
</div>
</section>
<!-- 4-5. Servizi privati e aziende -->
<section class="container block services" aria-label="Servizi">
@for (group of [home.privateServices, home.businessServices]; track group.title; let business = $last) {
<div class="services__group" [class.services__group--business]="business">
<h2>{{ group.title }}</h2>
<ul>
@for (item of group.items; track item.title) {
<li>
<h3>{{ item.title }}</h3>
<p>{{ item.text }}</p>
</li>
}
</ul>
<a [routerLink]="business ? '/soluzioni/industria' : '/contatti'" [queryParams]="business ? null : { oggetto: 'sopralluogo' }">
{{ business ? 'Soluzioni per le aziende' : 'Prenota un sopralluogo' }}
</a>
</div>
}
</section>
<!-- 6. Configuratore -->
<div class="container block">
<app-needs-picker [heading]="home.configurator.title" [lead]="home.configurator.lead" />
</div>
<!-- 7. Realizzazioni -->
<section class="container block" aria-labelledby="projects-title">
<div class="head">
<h2 id="projects-title">{{ home.projects.title }}</h2>
<a routerLink="/realizzazioni">Tutte le realizzazioni</a>
</div>
<ul class="projects">
@for (p of projects; track p.id) {
<li>
<img [ngSrc]="p.image.src" [width]="p.image.width" [height]="p.image.height" [alt]="p.image.alt" />
<p class="meta">{{ areaLabel(p.area) }} · {{ p.year }}</p>
<h3>{{ p.title }}</h3>
<p>{{ p.summary }}</p>
</li>
}
</ul>
</section>
<!-- 8. Partner e marchi -->
<section class="container block" aria-labelledby="partners-title">
<h2 id="partners-title">{{ home.partners.title }}</h2>
<ul class="partners">
@for (partner of home.partners.items; track partner) {
<li>{{ partner }}</li>
}
</ul>
</section>
<!-- 9. Recensioni -->
<section class="reviews" aria-labelledby="reviews-title">
<div class="container">
<h2 id="reviews-title">{{ home.reviews.title }}</h2>
<ul>
@for (review of home.reviews.items; track $index) {
<li>
<blockquote>
<p>{{ review.text }}</p>
</blockquote>
<p class="author">{{ review.author }}, {{ review.place }}</p>
</li>
}
</ul>
</div>
</section>
<!-- 10. Ultimi articoli (API) -->
<div class="container block">
<app-latest-posts [heading]="home.blog.title" />
</div>
<!-- 11. Contatti -->
<section class="container block contact" aria-labelledby="contact-title">
<div>
<h2 id="contact-title">{{ home.contact.title }}</h2>
<p class="lead">{{ home.contact.text }}</p>
<div class="actions">
<a class="btn btn--primary" routerLink="/contatti">Scrivici</a>
<a class="btn btn--outline" routerLink="/showroom">Prenota una visita</a>
</div>
</div>
<dl>
@if (company.phone) {
<dt>Telefono</dt>
<dd><a [href]="'tel:' + company.phone">{{ company.phone }}</a></dd>
}
@if (company.email) {
<dt>Email</dt>
<dd><a [href]="'mailto:' + company.email">{{ company.email }}</a></dd>
}
@if (company.address; as a) {
<dt>Showroom</dt>
<dd>{{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }})</dd>
}
<dt>Orari</dt>
@for (line of company.openingHoursText; track line) {
<dd>{{ line }}</dd>
}
</dl>
</section>
+326
View File
@@ -0,0 +1,326 @@
@use 'mixins' as mq;
.block {
padding-block: var(--space-7);
@include mq.up(md) {
padding-block: var(--space-8);
}
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
h2 {
margin: 0;
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: var(--font-weight-semibold);
}
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-5);
}
// --- Hero ---
.hero {
padding-block: var(--space-8);
background: linear-gradient(135deg, var(--color-primary-strong), var(--color-primary) 60%, var(--color-secondary));
color: var(--color-on-primary);
h1 {
max-width: 18ch;
color: inherit;
}
.payoff {
max-width: 55ch;
font-size: var(--font-size-lg);
color: #e3eef2;
}
.btn--outline {
color: var(--color-on-primary);
}
@include mq.up(md) {
padding-block: var(--space-9);
}
}
// --- Settori ---
.sectors {
display: grid;
gap: var(--space-4);
margin: var(--space-5) 0 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(2, 1fr);
}
@include mq.up(lg) {
grid-template-columns: repeat(4, 1fr);
}
}
.sector {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-top: 4px solid var(--color-primary);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
&[data-area='acqua'] { border-top-color: var(--color-area-acqua); }
&[data-area='energia'] { border-top-color: var(--color-area-energia); }
&[data-area='comfort'] { border-top-color: var(--color-area-comfort); }
&[data-area='mobilita'] { border-top-color: var(--color-area-mobilita); }
&[data-area='industria'] { border-top-color: var(--color-area-industria); }
h3 {
font-size: var(--font-size-xl);
}
p {
color: var(--color-ink-muted);
}
&--featured {
display: grid;
gap: var(--space-5);
align-items: center;
background: var(--color-primary-soft);
@include mq.up(md) {
grid-column: 1 / -1;
grid-template-columns: 2fr 3fr;
}
h3 {
font-size: var(--font-size-2xl);
}
}
&__img {
width: 100%;
height: auto;
border-radius: var(--radius-md);
}
}
// --- Energia ---
.energy {
padding-block: var(--space-8);
background: var(--color-accent-soft);
&__grid {
display: grid;
gap: var(--space-6);
align-items: center;
@include mq.up(md) {
grid-template-columns: 3fr 2fr;
}
}
&__img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
}
}
.checks {
margin: 0 0 var(--space-5);
padding: 0;
list-style: none;
li {
position: relative;
padding-left: var(--space-6);
margin-bottom: var(--space-2);
&::before {
content: '✓';
position: absolute;
left: 0;
font-weight: var(--font-weight-bold);
color: var(--color-secondary);
}
}
}
// --- Servizi ---
.services {
display: grid;
gap: var(--space-6);
@include mq.up(lg) {
grid-template-columns: repeat(2, 1fr);
}
ul {
display: grid;
gap: var(--space-3);
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
@include mq.up(sm) {
grid-template-columns: repeat(2, 1fr);
}
}
li {
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
}
h3 {
font-size: var(--font-size-md);
margin-bottom: var(--space-1);
}
li p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: var(--font-weight-semibold);
}
}
.services__group--business li {
background: #eef1f5;
}
// --- Realizzazioni ---
.projects {
display: grid;
gap: var(--space-5);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(3, 1fr);
}
img {
width: 100%;
height: auto;
margin-bottom: var(--space-3);
border-radius: var(--radius-lg);
}
h3 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-2);
}
p:last-child {
color: var(--color-ink-muted);
}
}
.meta {
margin-bottom: var(--space-1);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-secondary);
}
// --- Partner ---
.partners {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(6, 1fr);
}
li {
display: grid;
place-items: center;
min-height: 80px;
padding: var(--space-3);
border: 1px dashed var(--color-line);
border-radius: var(--radius-md);
color: var(--color-ink-muted);
font-size: var(--font-size-sm);
text-align: center;
}
}
// --- Recensioni ---
.reviews {
padding-block: var(--space-8);
background: var(--color-surface-alt);
ul {
display: grid;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(3, 1fr);
}
}
li {
padding: var(--space-5);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
blockquote {
margin: 0 0 var(--space-3);
p {
font-style: italic;
}
}
.author {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
}
// --- Contatti ---
.contact {
display: grid;
gap: var(--space-6);
@include mq.up(md) {
grid-template-columns: 3fr 2fr;
align-items: center;
}
dl {
margin: 0;
padding: var(--space-5);
border-radius: var(--radius-lg);
background: var(--color-primary-soft);
}
dt {
margin-top: var(--space-3);
font-weight: var(--font-weight-semibold);
&:first-child {
margin-top: 0;
}
}
dd {
margin: 0;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NgOptimizedImage } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { AREAS, findArea } from '../../content/areas'
import { HOME } from '../../content/home'
import { PROJECTS } from '../../content/projects'
import { SITE_CONFIG } from '../core/app-config'
import { SeoService } from '../core/seo.service'
import { COMPANY } from '../core/site'
import { localBusinessJsonLd, organizationJsonLd, websiteJsonLd } from '../core/structured-data'
import { LatestPosts } from '../shared/latest-posts'
import { NeedsPicker } from '../shared/needs-picker'
import { PlaceholderBadge } from '../shared/placeholder-badge'
/**
* Home. Testi in src/content/home.ts; sezioni in quest'ordine: hero, settori (Acqua in evidenza), energia
* rinnovabile, servizi privati, servizi aziende, configuratore, realizzazioni, partner, recensioni, blog, contatti.
*/
@Component({
selector: 'app-home',
imports: [NgOptimizedImage, RouterLink, LatestPosts, NeedsPicker, PlaceholderBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './home.html',
styleUrl: './home.scss',
})
export default class Home {
protected readonly home = HOME
protected readonly company = COMPANY
/** Acqua in evidenza (card grande), poi le altre aree. */
protected readonly featuredArea = AREAS.find((a) => a.slug === 'acqua') ?? AREAS[0]!
protected readonly otherAreas = AREAS.filter((a) => a !== this.featuredArea)
protected readonly energy = findArea('energia')
protected readonly projects = PROJECTS.filter((p) => p.featured).slice(0, 3)
protected readonly areaLabel = (slug: string) => findArea(slug)?.label ?? slug
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
seo.setPage({ title: HOME.seo.title, description: HOME.seo.description, path: '/' })
seo.setJsonLd('organization', [organizationJsonLd(siteUrl), websiteJsonLd(siteUrl), localBusinessJsonLd(siteUrl)])
}
}
+22
View File
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { injectResponseStatus } from '../core/response-status'
import { SeoService } from '../core/seo.service'
import { NotFoundView } from '../shared/not-found-view'
/** Pagina 404. Sul server imposta lo status HTTP 404 (RESPONSE_INIT), così i motori di ricerca non la indicizzano. */
@Component({
selector: 'app-not-found-page',
imports: [NotFoundView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<app-not-found-view />`,
})
export default class NotFoundPage {
constructor() {
injectResponseStatus()(404)
inject(SeoService).setPage({
title: 'Pagina non trovata',
description: 'La pagina richiesta non esiste o è stata spostata.',
noindex: true,
})
}
}
@@ -0,0 +1,190 @@
import { afterNextRender, ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'
import { RouterLink } from '@angular/router'
import { SITE_CONFIG } from '../core/app-config'
import { formatDate, formatEuro } from '../core/format'
import { type LastOrder, readLastOrder } from '../core/order-confirmation'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { PageHeader } from '../shared/page-header'
/**
* /ordine/:numero/confermato (noindex). Il riepilogo viene dal sessionStorage della scheda in cui si è pagato
* (core/order-confirmation.ts): il server e le altre schede mostrano solo il messaggio generico.
* Agli ospiti propone di creare un account: gli ordini fatti con la stessa email vengono collegati
* automaticamente quando l'email viene verificata.
*/
@Component({
selector: 'app-order-confirmed-page',
imports: [RouterLink, PageHeader],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header heading="Grazie per il tuo ordine!" [crumbs]="[{ label: 'Ordine confermato' }]" />
<div class="container body">
<div class="layout">
<section>
<p class="alert alert--success" role="status">
Ordine <strong>n. {{ numero() }}</strong> confermato.
@if (last(); as l) {
Ti abbiamo inviato il riepilogo a <strong>{{ l.order.email }}</strong>.
} @else {
Riceverai a breve un'email con il riepilogo.
}
</p>
@if (last(); as l) {
<h2>Riepilogo</h2>
<p class="muted">Effettuato il {{ date(l.order.createdAt) }}</p>
<table class="items">
<caption class="visually-hidden">Prodotti ordinati</caption>
<thead>
<tr>
<th scope="col">Prodotto</th>
<th scope="col" class="num">Quantità</th>
<th scope="col" class="num">Totale</th>
</tr>
</thead>
<tbody>
@for (item of l.order.items; track $index) {
<tr>
<td>
{{ item.productTitle }}
@if (item.variantTitle && item.variantTitle !== 'Standard') {
<span class="muted">— {{ item.variantTitle }}</span>
}
</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">{{ euro(item.total) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="2">Prodotti</th>
<td class="num">{{ euro(l.order.totals.items) }}</td>
</tr>
<tr>
<th scope="row" colspan="2">Spedizione</th>
<td class="num">{{ euro(l.order.totals.shipping) }}</td>
</tr>
<tr class="total">
<th scope="row" colspan="2">Totale pagato</th>
<td class="num">{{ euro(l.order.totals.total) }}</td>
</tr>
<tr class="muted">
<th scope="row" colspan="2">di cui IVA</th>
<td class="num">{{ euro(l.order.totals.tax) }}</td>
</tr>
</tfoot>
</table>
}
<p><a class="btn btn--outline" routerLink="/shop">Torna allo shop</a></p>
</section>
<aside class="next">
@if (guest()) {
<h2>Segui il tuo ordine</h2>
<p>
Crea un account con la stessa email usata per l'ordine: appena la confermi, troverai questo ordine (e quelli
precedenti) nella tua area riservata, con stato e spedizione.
</p>
<a class="btn btn--primary" [href]="registerUrl()">Crea un account</a>
} @else {
<h2>I tuoi ordini</h2>
<p>Puoi seguire stato e spedizione dell'ordine dalla tua area riservata.</p>
<a class="btn btn--primary" [href]="ordersUrl">Vai ai tuoi ordini</a>
}
<p class="muted help">Per qualsiasi domanda sull'ordine <a routerLink="/contatti">contattaci</a> indicando il numero.</p>
</aside>
</div>
</div>
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.layout {
display: grid;
gap: var(--space-6);
@include mq.up(lg) {
grid-template-columns: 2fr 1fr;
align-items: start;
}
}
h2 {
font-size: var(--font-size-xl);
}
.muted {
color: var(--color-ink-muted);
}
.items {
width: 100%;
margin-bottom: var(--space-5);
border-collapse: collapse;
th,
td {
padding: var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-line);
text-align: left;
}
.num {
text-align: right;
white-space: nowrap;
}
tfoot th {
font-weight: var(--font-weight-regular);
text-align: right;
}
.total {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
th {
font-weight: var(--font-weight-bold);
}
}
}
.next {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
}
.help {
margin: var(--space-4) 0 0;
font-size: var(--font-size-sm);
}
`,
})
export default class OrderConfirmedPage {
/** Numero d'ordine dalla route. */
readonly numero = input.required<string>()
private readonly config = inject(SITE_CONFIG)
private readonly session = inject(SessionService)
protected readonly last = signal<LastOrder | null>(null)
protected readonly euro = formatEuro
protected readonly date = formatDate
/** Ospite: dall'ordine salvato, altrimenti dalla sessione (finché non è nota, si propone l'account). */
protected readonly guest = computed(() => this.last()?.guest ?? this.session.status() !== 'authenticated')
protected readonly registerUrl = computed(() => {
const email = this.last()?.order.email
return `${this.config.privateAppUrl}/registrazione${email ? `?email=${encodeURIComponent(email)}` : ''}`
})
protected readonly ordersUrl = `${this.config.privateAppUrl}/ordini`
constructor() {
inject(SeoService).setPage({
title: 'Ordine confermato',
description: 'Il tuo ordine ITI TECHNOLOGY è stato confermato.',
noindex: true,
})
// Solo nel browser: il server non ha il sessionStorage (stesso HTML per tutti, poi il riepilogo)
afterNextRender(() => this.last.set(readLastOrder(Number(this.numero()))))
}
}
+24
View File
@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { PRIVACY_POLICY } from '../../content/legal'
import { SeoService } from '../core/seo.service'
import { LegalPage } from '../shared/legal-page'
/** /privacy: informativa privacy da src/content/legal.ts (testo da validare). */
@Component({
selector: 'app-privacy-page',
imports: [LegalPage, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-legal-page [page]="page">
<p><a routerLink="/cookie">Vai alla cookie policy</a></p>
</app-legal-page>
`,
})
export default class PrivacyPage {
protected readonly page = PRIVACY_POLICY
constructor() {
inject(SeoService).setPage({ title: this.page.title, description: this.page.description, path: '/privacy' })
}
}
+526
View File
@@ -0,0 +1,526 @@
import type { DocumentDto, Paginated, ShopProductDetailDto } from '@iti/shared'
import { NgOptimizedImage } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, linkedSignal, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { catchError, map, of } from 'rxjs'
import { ApiError, ApiService } from '../core/api.service'
import { SITE_CONFIG } from '../core/app-config'
import { CartStore } from '../core/cart.store'
import { formatBytes, formatEuro } from '../core/format'
import { plainTextExcerpt } from '../core/listing'
import { injectResponseStatus } from '../core/response-status'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { defaultVariant, galleryImages, MAX_QUANTITY, productPath, shopListPath } from '../core/shop'
import { breadcrumbJsonLd, productJsonLd } from '../core/structured-data'
import { NotFoundView } from '../shared/not-found-view'
import { PageHeader } from '../shared/page-header'
/** Etichetta leggibile del tipo di documento. */
const DOC_TYPES: Record<string, string> = {
'application/pdf': 'PDF',
'application/zip': 'ZIP',
'image/vnd.dwg': 'DWG',
'image/vnd.dxf': 'DXF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
}
/**
* /shop/:handle: scheda prodotto da GET /shop/products/:handle, renderizzata sul server con i prezzi pubblici
* (IVA inclusa) e JSON-LD Product. Nel browser, per un installatore, il prodotto si ricarica con il token e
* mostra il prezzo riservato. Handle inesistente → 404 reale; API giù → 503.
*/
@Component({
selector: 'app-product-page',
imports: [NgOptimizedImage, RouterLink, PageHeader, NotFoundView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (notFound()) {
<app-not-found-view heading="Prodotto non trovato" lead="Il prodotto che cerchi non esiste o non è più disponibile." />
} @else if (failed()) {
<app-page-header heading="Shop" [crumbs]="[{ label: 'Shop', path: '/shop' }]" />
<div class="container body">
<p class="notice">Lo shop non è raggiungibile in questo momento. Riprova tra qualche istante.</p>
</div>
} @else if (product(); as p) {
<app-page-header [heading]="p.title" [lead]="p.subtitle ?? undefined" [crumbs]="crumbs()" />
<div class="container body">
<div class="layout">
<section class="gallery" aria-label="Immagini del prodotto">
<div class="main-image">
@if (images()[imageIndex()]; as src) {
<img [ngSrc]="src" fill priority [alt]="p.title + (images().length > 1 ? ', immagine ' + (imageIndex() + 1) : '')" />
} @else {
<span class="no-image">Immagine non disponibile</span>
}
</div>
@if (images().length > 1) {
<ul class="thumbs">
@for (src of images(); track src; let i = $index) {
<li>
<button
type="button"
[attr.aria-label]="'Mostra immagine ' + (i + 1) + ' di ' + images().length"
[attr.aria-pressed]="i === imageIndex()"
(click)="imageIndex.set(i)"
>
<img [ngSrc]="src" width="80" height="80" alt="" />
</button>
</li>
}
</ul>
}
</section>
<section class="buy" aria-label="Acquisto">
@if (variant(); as v) {
<div class="price-box">
@if (v.price !== null) {
<p class="price">
{{ euro(v.price) }} <span class="vat">IVA inclusa</span>
</p>
@if (p.priceType === 'installer' && v.originalPrice !== null && v.originalPrice !== v.price) {
<p class="reserved">
Prezzo riservato installatori · listino <s>{{ euro(v.originalPrice) }}</s>
</p>
}
} @else {
<p class="na">Prezzo su richiesta</p>
}
<p class="stock" [class.out]="!v.inStock">{{ v.inStock ? 'Disponibile' : 'Non disponibile' }}</p>
@if (v.sku) {
<p class="sku">Codice: {{ v.sku }}</p>
}
</div>
<form (submit)="addToCart($event)">
@if (p.variants.length > 1) {
<fieldset class="variants">
<legend>Versione</legend>
@for (option of p.variants; track option.id) {
<label class="variant" [class.selected]="option.id === v.id">
<input type="radio" name="variant" [value]="option.id" [checked]="option.id === v.id" (change)="selectVariant(option.id)" />
<span class="variant-title">{{ option.title }}</span>
<span class="variant-price">{{ option.price !== null ? euro(option.price) : 'su richiesta' }}</span>
@if (!option.inStock) {
<span class="variant-out">non disponibile</span>
}
</label>
}
</fieldset>
}
<div class="add">
<label class="qty">
<span>Quantità</span>
<input type="number" inputmode="numeric" min="1" [max]="maxQuantity" [value]="quantity()" (change)="setQuantity($event)" />
</label>
<button class="btn btn--accent" type="submit" [disabled]="!canBuy() || adding()">
{{ adding() ? 'Aggiunta in corso…' : 'Aggiungi al carrello' }}
</button>
</div>
<p class="feedback" role="status">
@if (added()) {
Aggiunto al carrello. <a routerLink="/carrello">Vai al carrello</a>
}
</p>
@if (addError(); as message) {
<p class="error" role="alert">{{ message }}</p>
}
</form>
}
<p class="hint">Hai bisogno di installazione o di un consiglio? <a routerLink="/contatti" [queryParams]="contactQuery()">Chiedi un preventivo</a></p>
</section>
</div>
@if (p.description) {
<section class="description" aria-labelledby="desc-title">
<h2 id="desc-title">Descrizione</h2>
<p>{{ p.description }}</p>
</section>
}
@if (documents().length) {
<section class="documents" aria-labelledby="docs-title">
<h2 id="docs-title">Schede tecniche e documenti</h2>
<ul>
@for (d of documents(); track d.id) {
<li>
<a [href]="downloadUrl(d.id)" download>{{ d.title }}</a>
<span class="doc-meta">{{ docType(d.mimeType) }} · {{ bytes(d.sizeBytes) }}</span>
</li>
}
</ul>
</section>
}
</div>
}
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.notice {
padding: var(--space-5);
border-left: 4px solid var(--color-danger);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
}
.layout {
display: grid;
gap: var(--space-6);
@include mq.up(lg) {
grid-template-columns: 3fr 2fr;
align-items: start;
}
}
.main-image {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 4 / 3;
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
img {
object-fit: contain;
}
}
.no-image {
color: var(--color-ink-muted);
font-size: var(--font-size-sm);
}
.thumbs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
button {
display: block;
padding: 2px;
border: 2px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
&[aria-pressed='true'] {
border-color: var(--color-primary);
}
}
img {
display: block;
object-fit: contain;
}
}
.buy {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
@include mq.up(lg) {
position: sticky;
top: calc(var(--header-height) + var(--space-4));
}
}
.price-box p {
margin: 0 0 var(--space-2);
}
.price {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
line-height: var(--line-height-tight);
}
.vat {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
color: var(--color-ink-muted);
}
.reserved {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-secondary);
}
.stock {
font-weight: var(--font-weight-semibold);
color: var(--color-success);
&.out {
color: var(--color-danger);
}
}
.sku,
.na {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.variants {
display: grid;
gap: var(--space-2);
margin: var(--space-4) 0 0;
padding: 0;
border: 0;
legend {
margin-bottom: var(--space-2);
font-weight: var(--font-weight-semibold);
}
}
.variant {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
cursor: pointer;
&.selected {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
}
.variant-title {
flex: 1;
}
.variant-price {
font-weight: var(--font-weight-semibold);
}
.variant-out {
width: 100%;
font-size: var(--font-size-xs);
color: var(--color-danger);
}
.add {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-3);
margin-top: var(--space-4);
.btn {
flex: 1;
min-width: 12rem;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.qty {
display: grid;
gap: var(--space-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
input {
width: 5.5rem;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
font: inherit;
}
}
.feedback {
min-height: 1.5em;
margin: var(--space-3) 0 0;
color: var(--color-success);
font-weight: var(--font-weight-semibold);
}
.error {
margin: var(--space-2) 0 0;
color: var(--color-danger);
}
.hint {
margin: var(--space-4) 0 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.description,
.documents {
max-width: 72ch;
margin-top: var(--space-7);
h2 {
font-size: var(--font-size-2xl);
}
}
.description p {
white-space: pre-line;
}
.documents ul {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-height: 44px;
border-bottom: 1px solid var(--color-line);
}
a {
font-weight: var(--font-weight-semibold);
}
}
.doc-meta {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
`,
})
export default class ProductPage {
readonly handle = input.required<string>()
private readonly api = inject(ApiService)
private readonly session = inject(SessionService)
private readonly cart = inject(CartStore)
private readonly productRes = rxResource({
params: () => ({ handle: this.handle(), prices: this.session.priceKey() }),
stream: ({ params }) =>
this.api.get<ShopProductDetailDto>(`/shop/products/${encodeURIComponent(params.handle)}`, { auth: true }),
})
/** Il prodotto resta visibile mentre si ricarica con i prezzi riservati (stesso handle), senza sfarfallio. */
protected readonly product = linkedSignal<{ value: ShopProductDetailDto | undefined; handle: string }, ShopProductDetailDto | undefined>({
source: () => ({ value: this.productRes.hasValue() ? this.productRes.value() : undefined, handle: this.handle() }),
computation: (src, prev) => src.value ?? (prev && prev.source.handle === src.handle ? prev.value : undefined),
})
private readonly error = computed(() => this.productRes.error())
protected readonly notFound = computed(() => {
const err = this.error()
return err instanceof ApiError && err.status === 404
})
protected readonly failed = computed(() => this.error() !== undefined && !this.notFound())
/** Schede tecniche pubbliche collegate al prodotto (documents.product_ref = handle). Errori → sezione nascosta. */
private readonly documentsRes = rxResource({
params: () => this.handle(),
stream: ({ params }) =>
this.api.get<Paginated<DocumentDto>>('/documents', { query: { productRef: params, limit: 50 } }).pipe(
// Anche se l'API ne restituisse altri, qui solo i pubblici: il link di download non porta il token
map((page) => page.items.filter((d) => d.visibility === 'public')),
catchError(() => of([] as DocumentDto[])),
),
})
protected readonly documents = computed(() => (this.documentsRes.hasValue() ? this.documentsRes.value() : []))
protected readonly images = computed(() => {
const p = this.product()
return p ? galleryImages(p) : []
})
protected readonly imageIndex = linkedSignal({ source: this.handle, computation: () => 0 })
/** Variante scelta: si azzera cambiando prodotto. */
private readonly variantId = linkedSignal<string, string | null>({ source: this.handle, computation: () => null })
protected readonly variant = computed(() => {
const p = this.product()
if (!p) return undefined
return p.variants.find((v) => v.id === this.variantId()) ?? defaultVariant(p)
})
protected readonly quantity = linkedSignal({ source: this.handle, computation: () => 1 })
protected readonly canBuy = computed(() => {
const v = this.variant()
return !!v && v.inStock && v.price !== null
})
protected readonly adding = signal(false)
protected readonly added = signal(false)
protected readonly addError = signal<string | null>(null)
protected readonly crumbs = computed(() => {
const p = this.product()
if (!p) return []
const category = p.categories[0]
return category
? [{ label: 'Shop', path: '/shop' }, { label: category.name, path: shopListPath(category.handle) }, { label: p.title }]
: [{ label: 'Shop', path: '/shop' }, { label: p.title }]
})
protected readonly contactQuery = computed(() => ({ oggetto: 'preventivo', prodotto: this.handle() }))
protected readonly maxQuantity = MAX_QUANTITY
protected readonly euro = formatEuro
protected readonly bytes = formatBytes
protected readonly docType = (mime: string) => DOC_TYPES[mime] ?? 'File'
protected readonly downloadUrl = (id: string) => this.api.url(`/documents/${encodeURIComponent(id)}/download`)
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
const setStatus = injectResponseStatus()
effect(() => {
if (this.notFound()) {
setStatus(404)
seo.setPage({ title: 'Prodotto non trovato', description: 'Il prodotto richiesto non esiste o non è più disponibile.', noindex: true })
return
}
if (this.failed()) {
setStatus(503)
seo.setPage({ title: 'Shop', description: 'Shop ITI TECHNOLOGY.', noindex: true })
return
}
const p = this.product()
// Meta e JSON-LD solo con i prezzi pubblici (quelli dell'SSR): i prezzi riservati non sono un'offerta pubblica
if (!p || p.priceType !== 'standard') return
const path = productPath(p.handle)
const description = plainTextExcerpt(
[p.subtitle, p.description].filter(Boolean).join('. ') || `${p.title}: acquista online con prezzo IVA inclusa.`,
)
const image = galleryImages(p)[0]
seo.setPage({ title: p.title, description, path, type: 'product', ...(image ? { image } : {}) })
seo.setJsonLd('product', productJsonLd(siteUrl, p, description))
const category = p.categories[0]
seo.setJsonLd(
'breadcrumb',
breadcrumbJsonLd(siteUrl, [
{ name: 'Shop', path: '/shop' },
...(category ? [{ name: category.name, path: shopListPath(category.handle) }] : []),
{ name: p.title, path },
]),
)
})
}
protected selectVariant(id: string): void {
this.variantId.set(id)
this.added.set(false)
}
protected setQuantity(event: Event): void {
const input = event.target as HTMLInputElement
const value = Math.min(MAX_QUANTITY, Math.max(1, Math.trunc(Number(input.value)) || 1))
input.value = String(value)
this.quantity.set(value)
}
protected async addToCart(event: Event): Promise<void> {
event.preventDefault()
const v = this.variant()
if (!v || !this.canBuy() || this.adding()) return
this.adding.set(true)
this.added.set(false)
this.addError.set(null)
const ok = await this.cart.add(v.id, this.quantity())
this.adding.set(false)
if (ok) this.added.set(true)
else this.addError.set(this.cart.error() ?? 'Non è stato possibile aggiungere il prodotto al carrello.')
}
}
+151
View File
@@ -0,0 +1,151 @@
import { NgOptimizedImage } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { AREAS, findArea } from '../../content/areas'
import { PROJECTS } from '../../content/projects'
import { SeoService } from '../core/seo.service'
import { PageHeader } from '../shared/page-header'
import { PlaceholderBadge } from '../shared/placeholder-badge'
import { QuoteCta } from '../shared/quote-cta'
/**
* /realizzazioni: galleria da src/content/projects.ts, filtrabile per area con ?area=<slug>
* (link veri, quindi il filtro funziona anche senza JavaScript e lato server).
*/
@Component({
selector: 'app-projects-page',
imports: [NgOptimizedImage, RouterLink, PageHeader, PlaceholderBadge, QuoteCta],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header heading="Realizzazioni" lead="Alcuni degli impianti che abbiamo progettato e installato." [crumbs]="[{ label: 'Realizzazioni' }]">
<app-placeholder-badge badge [show]="hasPlaceholders" />
</app-page-header>
<div class="container body">
<nav aria-label="Filtra per area">
<ul class="filters">
<li><a routerLink="/realizzazioni" [attr.aria-current]="!selected() ? 'true' : null">Tutte</a></li>
@for (a of areas; track a.slug) {
<li>
<a routerLink="/realizzazioni" [queryParams]="{ area: a.slug }" [attr.aria-current]="selected() === a.slug ? 'true' : null">{{ a.label }}</a>
</li>
}
</ul>
</nav>
<ul class="gallery">
@for (p of projects(); track p.id; let i = $index) {
<li class="card">
<img [ngSrc]="p.image.src" [width]="p.image.width" [height]="p.image.height" [alt]="p.image.alt" [priority]="i < 2" />
<div class="text">
<p class="meta">{{ areaLabel(p.area) }} · {{ p.location }} · {{ p.year }}</p>
<h2>{{ p.title }}</h2>
<p>{{ p.summary }}</p>
</div>
</li>
} @empty {
<li class="empty">Nessuna realizzazione in quest'area per ora.</li>
}
</ul>
<app-quote-cta heading="Vuoi un risultato così?" />
</div>
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-4);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0 0 var(--space-5);
padding: 0;
list-style: none;
a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-inline: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
color: var(--color-ink);
text-decoration: none;
&[aria-current='true'] {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary);
}
}
}
.gallery {
display: grid;
gap: var(--space-5);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(2, 1fr);
}
@include mq.up(lg) {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
img {
width: 100%;
height: auto;
}
}
.text {
padding: var(--space-4) var(--space-5) var(--space-5);
h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-2);
}
p:last-child {
margin: 0;
color: var(--color-ink-muted);
}
}
.meta {
margin-bottom: var(--space-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-secondary);
}
`,
})
export default class ProjectsPage {
/** Query param ?area= (withComponentInputBinding). */
readonly area = input<string>()
protected readonly areas = AREAS
protected readonly hasPlaceholders = PROJECTS.some((p) => p.placeholder)
protected readonly selected = computed(() => (this.area() && findArea(this.area()!) ? this.area() : undefined))
protected readonly projects = computed(() => {
const area = this.selected()
return area ? PROJECTS.filter((p) => p.area === area) : PROJECTS
})
protected readonly areaLabel = (slug: string) => findArea(slug)?.label ?? slug
constructor() {
// Una sola pagina canonica: i filtri ?area= puntano tutti a /realizzazioni
inject(SeoService).setPage({
title: 'Realizzazioni',
description: 'Impianti fotovoltaici, pompe di calore, sistemi di trattamento acqua e ricarica realizzati da ITI TECHNOLOGY.',
path: '/realizzazioni',
})
}
}
+249
View File
@@ -0,0 +1,249 @@
import type { Paginated, ShopCategoryDto, ShopProductSummaryDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, linkedSignal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../core/api.service'
import { SITE_CONFIG } from '../core/app-config'
import { listQueryParams, listUrl, normalizeQuery, parsePage, totalPages } from '../core/listing'
import { injectResponseStatus } from '../core/response-status'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import { SHOP_PAGE_SIZE, SHOP_QUERY_MAX, shopListPath } from '../core/shop'
import { breadcrumbJsonLd } from '../core/structured-data'
import { FilterNav } from '../shared/filter-nav'
import { ListSearch } from '../shared/list-search'
import { NotFoundView } from '../shared/not-found-view'
import { PageHeader } from '../shared/page-header'
import { Pagination } from '../shared/pagination'
import { ProductCard } from '../shared/product-card'
const DEFAULT_LEAD = 'Prodotti per il trattamento dell’acqua, l’energia e il comfort, con prezzi IVA inclusa.'
const DEFAULT_DESCRIPTION =
'Acquista online addolcitori, kit fotovoltaici, pompe di calore e accessori, con prezzi IVA inclusa e spedizione in tutta Italia.'
/**
* /shop e /shop/categoria/:categoria, con ricerca (?q=) e paginazione (?pagina=): stato nell'URL, come il blog.
* Categoria inesistente, pagina non valida o oltre l'ultima → 404 reale. Dati solo da GET /shop/* dell'API.
*/
@Component({
selector: 'app-shop-page',
imports: [RouterLink, PageHeader, ProductCard, NotFoundView, ListSearch, Pagination, FilterNav],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (notFound()) {
<app-not-found-view />
} @else {
<app-page-header [heading]="heading()" [lead]="lead()" [crumbs]="crumbs()" />
<div class="container body">
<div class="toolbar">
<app-list-search
[path]="listPath()"
[query]="q()"
[page]="current()"
[maxLength]="queryMax"
inputId="shop-q"
label="Cerca nello shop"
placeholder="Es. addolcitore, kit fotovoltaico"
/>
@if (filters().length > 1) {
<app-filter-nav label="Categorie dello shop" [links]="filters()" [queryParams]="filterQuery()" />
}
</div>
<p class="status" role="status">{{ statusText() }}</p>
@if (failed()) {
<p class="notice">Lo shop non è raggiungibile in questo momento. Riprova tra qualche istante.</p>
} @else if (page(); as data) {
@if (data.items.length) {
<h2 class="visually-hidden">Prodotti</h2>
<ul class="grid">
@for (product of data.items; track product.handle; let i = $index) {
<li><app-product-card [product]="product" [priority]="i < 4" /></li>
}
</ul>
} @else {
<div class="empty">
@if (q()) {
<p>Nessun prodotto trovato per «{{ q() }}»{{ category() ? ' in ' + category()!.name : '' }}.</p>
<p>Prova con parole diverse o <a [routerLink]="listPath()">mostra tutti i prodotti</a>.</p>
} @else {
<p>Non ci sono ancora prodotti{{ category() ? ' in questa categoria' : '' }}.</p>
<p><a routerLink="/contatti" [queryParams]="{ oggetto: 'preventivo' }">Chiedici un preventivo</a>: ti aiutiamo a trovare la soluzione giusta.</p>
}
</div>
}
<app-pagination label="Pagine dei prodotti" [path]="listPath()" [q]="q()" [current]="current()" [last]="lastPage()" />
}
</div>
}
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-5) var(--space-8);
}
.toolbar {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.status {
margin-bottom: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.grid {
display: grid;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(sm) {
grid-template-columns: repeat(2, 1fr);
}
@include mq.up(lg) {
grid-template-columns: repeat(3, 1fr);
}
@include mq.up(xl) {
grid-template-columns: repeat(4, 1fr);
}
}
.empty,
.notice {
padding: var(--space-5);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
p:last-child {
margin: 0;
}
}
.notice {
border-left: 4px solid var(--color-danger);
}
`,
})
export default class ShopPage {
/** Parametro di percorso di /shop/categoria/:categoria (handle Medusa). */
readonly categoria = input<string>()
/** Query params (?q=, ?pagina=). */
readonly q = input('', { transform: (v: string | undefined) => normalizeQuery(v, SHOP_QUERY_MAX) })
readonly pagina = input<string>()
private readonly api = inject(ApiService)
private readonly session = inject(SessionService)
protected readonly queryMax = SHOP_QUERY_MAX
/** Pagina richiesta; null = parametro non valido (404). */
private readonly requestedPage = computed(() => parsePage(this.pagina()))
protected readonly current = computed(() => this.requestedPage() ?? 1)
private readonly categoriesRes = rxResource({
stream: () => this.api.get<ShopCategoryDto[]>('/shop/categories'),
})
private readonly productsRes = rxResource({
params: () => {
const page = this.requestedPage()
if (page === null) return undefined
return { category: this.categoria() ?? null, q: this.q(), page, prices: this.session.priceKey() }
},
stream: ({ params }) =>
this.api.get<Paginated<ShopProductSummaryDto>>('/shop/products', {
query: { category: params.category, q: params.q, limit: SHOP_PAGE_SIZE, offset: (params.page - 1) * SHOP_PAGE_SIZE },
auth: true,
}),
})
private readonly categories = computed(() => (this.categoriesRes.hasValue() ? this.categoriesRes.value() : []))
protected readonly category = computed(() => {
const handle = this.categoria()
return handle ? this.categories().find((c) => c.handle === handle) : undefined
})
/**
* Pagina di prodotti. Se cambiano solo i prezzi (sessione da installatore scoperta dopo l'idratazione) resta
* visibile la versione precedente finché arriva quella nuova, senza sfarfallio.
*/
protected readonly page = linkedSignal<{ value: Paginated<ShopProductSummaryDto> | undefined; key: string }, Paginated<ShopProductSummaryDto> | undefined>({
source: () => ({
value: this.productsRes.hasValue() ? this.productsRes.value() : undefined,
key: `${this.categoria() ?? ''}|${this.q()}|${this.current()}`,
}),
computation: (src, prev) => src.value ?? (prev && prev.source.key === src.key ? prev.value : undefined),
})
protected readonly failed = computed(() => this.categoriesRes.status() === 'error' || this.productsRes.status() === 'error')
protected readonly lastPage = computed(() => totalPages(this.page()?.total ?? 0, SHOP_PAGE_SIZE))
protected readonly notFound = computed(() => {
const current = this.requestedPage()
if (current === null) return true
if (this.categoria() && this.categoriesRes.hasValue() && !this.category()) return true
const page = this.page()
return current > 1 && page !== undefined && current > totalPages(page.total, SHOP_PAGE_SIZE)
})
protected readonly listPath = computed(() => shopListPath(this.categoria()))
protected readonly heading = computed(() => this.category()?.name ?? 'Shop')
protected readonly lead = computed(() => this.category()?.description ?? DEFAULT_LEAD)
protected readonly crumbs = computed(() => {
const c = this.category()
return c ? [{ label: 'Shop', path: '/shop' }, { label: c.name }] : [{ label: 'Shop' }]
})
protected readonly filters = computed(() => [
{ label: 'Tutti', path: '/shop', active: !this.categoria() },
...this.categories().map((c) => ({ label: c.name, path: shopListPath(c.handle), active: c.handle === this.categoria() })),
])
protected readonly filterQuery = computed(() => listQueryParams(this.q(), 1))
protected readonly statusText = computed(() => {
const page = this.page()
if (!page) return this.productsRes.isLoading() ? 'Caricamento dei prodotti…' : ''
if (this.failed()) return ''
const n = page.total
const count = n === 1 ? '1 prodotto' : `${n} prodotti`
const where = this.q() ? `${n === 1 ? 'trovato' : 'trovati'} per «${this.q()}»` : ''
const pageInfo = this.lastPage() > 1 ? ` · pagina ${this.current()} di ${this.lastPage()}` : ''
return [count, where].filter(Boolean).join(' ') + pageInfo + ' · prezzi IVA inclusa'
})
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
const setStatus = injectResponseStatus()
effect(() => {
if (this.notFound()) {
setStatus(404)
seo.setPage({ title: 'Pagina non trovata', description: 'La pagina richiesta non esiste o è stata spostata.', noindex: true })
return
}
if (this.failed()) {
setStatus(503)
seo.setPage({ title: 'Shop', description: DEFAULT_DESCRIPTION, noindex: true })
return
}
const category = this.category()
const handle = this.categoria()
if (handle && !category) return // categorie non ancora arrivate
const current = this.current()
const q = this.q()
const base = category ? `${category.name} - Shop` : 'Shop'
const title = q ? `Ricerca «${q}» - ${base}` : current > 1 ? `${base} - pagina ${current}` : base
const path = shopListPath(handle)
seo.setPage({
title,
description: category?.description ?? (category ? `${category.name}: ${DEFAULT_DESCRIPTION}` : DEFAULT_DESCRIPTION),
path: listUrl(path, '', current),
noindex: q !== '',
prev: q || current <= 1 ? null : listUrl(path, '', current - 1),
next: q || current >= this.lastPage() ? null : listUrl(path, '', current + 1),
})
if (category) {
seo.setJsonLd('breadcrumb', breadcrumbJsonLd(siteUrl, [{ name: 'Shop', path: '/shop' }, { name: category.name, path }]))
}
})
}
}
+194
View File
@@ -0,0 +1,194 @@
<app-page-header
heading="Showroom"
lead="Prenota una visita: ti mostriamo dal vivo le soluzioni e rispondiamo alle tue domande."
[crumbs]="[{ label: 'Showroom' }]"
>
<app-placeholder-badge badge [show]="content.placeholder" />
</app-page-header>
<div class="container body">
<section class="intro" aria-labelledby="about-title">
<div>
<h2 id="about-title">Vieni a trovarci</h2>
@for (p of content.intro; track $index) {
<p>{{ p }}</p>
}
<ul class="highlights">
@for (h of content.highlights; track h.title) {
<li>
<strong>{{ h.title }}</strong>
<span>{{ h.text }}</span>
</li>
}
</ul>
<h3>Orari</h3>
<ul class="hours">
@for (line of company.openingHoursText; track line) {
<li>{{ line }}</li>
}
</ul>
@if (company.address; as a) {
<p>{{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }}) — <a routerLink="/contatti">come raggiungerci</a></p>
}
</div>
<ul class="photos">
@for (photo of content.photos; track photo.src; let first = $first) {
<li [class.photos__main]="first">
<img [ngSrc]="photo.src" [width]="photo.width" [height]="photo.height" [alt]="photo.alt" [priority]="first" />
</li>
}
</ul>
</section>
<section class="panel booking" aria-labelledby="booking-title">
@if (booking(); as b) {
<h2 id="booking-title" tabindex="-1">Visita prenotata</h2>
<p class="alert alert--success" role="status">
Ti aspettiamo <strong>{{ visit(b.requestedAt) }}</strong> ({{ b.people }} {{ b.people === 1 ? 'persona' : 'persone' }}).
Ti abbiamo inviato una email di conferma a <strong>{{ b.email }}</strong>.
</p>
<p class="muted">Se non puoi più venire, rispondi all'email di conferma o <a routerLink="/contatti">scrivici</a>.</p>
<p><button type="button" class="btn btn--outline" (click)="newBooking()">Prenota un'altra visita</button></p>
} @else if (received()) {
<h2 id="booking-title" tabindex="-1">Richiesta ricevuta</h2>
<p class="alert alert--success" role="status">Abbiamo ricevuto la tua richiesta: riceverai una email di conferma.</p>
} @else {
<h2 id="booking-title" tabindex="-1">Prenota una visita</h2>
<p class="muted">Visita gratuita di circa un'ora con un nostro tecnico. I campi senza indicazione sono obbligatori.</p>
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form id="booking-form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
<fieldset>
<legend>1. Scegli giorno e orario</legend>
@switch (slotsState()) {
@case ('loading') {
<p class="status" role="status">Caricamento delle disponibilità…</p>
}
@case ('error') {
<p class="alert alert--error" role="alert">Non è stato possibile caricare le disponibilità.</p>
<button type="button" class="btn btn--outline" (click)="loadSlots()">Riprova</button>
}
@default {
@if (data(); as d) {
<div class="calendar-nav">
<button type="button" class="btn btn--outline btn--small" [disabled]="!canGoBack()" (click)="previousPage()">
← Date precedenti
</button>
<button type="button" class="btn btn--outline btn--small" [disabled]="d.days.length === 0" (click)="nextPage()">
Date successive →
</button>
</div>
@if (d.days.length === 0) {
<p class="muted">Nessuna data prenotabile in questo periodo.</p>
} @else {
<ul class="days" aria-label="Giorni disponibili">
@for (day of d.days; track day.date) {
@let free = freeSlots(day);
<li>
<button
type="button"
class="day"
[class.day--full]="free === 0"
[attr.aria-pressed]="selectedDate() === day.date"
[attr.aria-label]="formatDay(day.date) + (free === 0 ? ', completo' : ', ' + free + (free === 1 ? ' orario libero' : ' orari liberi'))"
(click)="selectDay(day.date)"
>
<span class="day__name">{{ formatDay(day.date, { short: true }) }}</span>
<span class="day__free">{{ free === 0 ? 'completo' : free + (free === 1 ? ' orario' : ' orari') }}</span>
</button>
</li>
}
</ul>
@if (selectedDay(); as day) {
<fieldset class="slots" [attr.aria-describedby]="slotError() ? 'slot-error' : null">
<legend>Orari di {{ formatDay(day.date) }}</legend>
<div class="slot-grid">
@for (slot of day.slots; track slot.start) {
<label class="slot" [class.slot--checked]="selectedStart() === slot.start" [class.slot--full]="slot.available === 0">
<input
type="radio"
name="slot"
[value]="slot.start"
[checked]="selectedStart() === slot.start"
[disabled]="slot.available === 0"
(change)="selectSlot(slot.start)"
/>
<span class="slot__time">{{ time(slot.start) }}</span>
<span class="slot__free">
{{ slot.available === 0 ? 'completo' : slot.available + (slot.available === 1 ? ' posto' : ' posti') }}
</span>
</label>
}
</div>
</fieldset>
} @else {
<p class="muted">Nessun orario libero nelle date mostrate: prova con le date successive.</p>
}
}
}
}
}
@if (slotError(); as message) {
<p class="field-error" id="slot-error" tabindex="-1" role="alert">{{ message }}</p>
}
</fieldset>
<fieldset>
<legend>2. I tuoi dati</legend>
<div class="form-grid">
<app-form-field [control]="form.controls.name" label="Nome e cognome" autocomplete="name" [maxlength]="120" [submitted]="submitted()" />
<app-form-field
[control]="form.controls.email"
label="Email"
type="email"
autocomplete="email"
[maxlength]="254"
hint="Ti invieremo qui la conferma della prenotazione."
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.phone"
label="Telefono"
type="tel"
autocomplete="tel"
[maxlength]="30"
[required]="false"
[messages]="{ pattern: 'Numero di telefono non valido' }"
[submitted]="submitted()"
/>
<div class="form-field">
<label for="booking-people">Persone</label>
<select id="booking-people" formControlName="people">
@for (n of people(); track n) {
<option [ngValue]="n">{{ n }}</option>
}
</select>
</div>
<div class="form-field span-2">
<label for="booking-notes">Note <span class="optional">(facoltativo)</span></label>
<p class="hint" id="booking-notes-hint">Ad esempio cosa ti interessa vedere: così prepariamo la visita.</p>
<textarea id="booking-notes" rows="3" maxlength="1000" formControlName="notes" aria-describedby="booking-notes-hint"></textarea>
</div>
</div>
<app-consent-fields
[privacy]="form.controls.acceptPrivacy"
[marketing]="form.controls.acceptMarketing"
purpose="gestire la prenotazione"
[showError]="submitted() && !form.controls.acceptPrivacy.value"
/>
<app-honeypot [(value)]="honeypot" />
</fieldset>
<div class="actions">
@if (selectedSlot(); as s) {
<p class="summary">Visita: <strong>{{ visit(s.start) }}</strong></p>
}
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Prenotazione in corso…' : 'Prenota la visita' }}</button>
</div>
</form>
}
</section>
</div>
+219
View File
@@ -0,0 +1,219 @@
@use 'mixins' as mq;
.body {
padding-block: var(--space-6) var(--space-8);
}
.muted,
.status {
color: var(--color-ink-muted);
}
h2 {
font-size: var(--font-size-2xl);
&:focus {
outline: none;
}
}
h3 {
margin-top: var(--space-5);
font-size: var(--font-size-lg);
}
// --- Descrizione e foto ---
.intro {
display: grid;
gap: var(--space-6);
margin-bottom: var(--space-7);
@include mq.up(lg) {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.highlights {
display: grid;
gap: var(--space-3);
margin: var(--space-4) 0 0;
padding: 0;
list-style: none;
li {
display: flex;
flex-direction: column;
padding: var(--space-3) var(--space-4);
border-left: 4px solid var(--color-secondary);
border-radius: var(--radius-md);
background: var(--color-secondary-soft);
}
}
.hours {
padding-left: var(--space-5);
}
.photos {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
&__main {
grid-column: 1 / -1;
}
img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-lg);
}
}
// --- Prenotazione ---
.panel {
max-width: 56rem;
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
@include mq.up(md) {
padding: var(--space-6);
}
}
.calendar-nav {
display: flex;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.btn--small {
min-height: 44px;
padding-inline: var(--space-4);
font-size: var(--font-size-sm);
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.days {
display: flex;
gap: var(--space-2);
margin: 0 0 var(--space-4);
padding: 0 0 var(--space-2);
overflow-x: auto;
list-style: none;
}
.day {
display: flex;
flex-direction: column;
align-items: center;
min-width: 6.5rem;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-ink);
font: inherit;
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
&[aria-pressed='true'] {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary);
.day__free {
color: inherit;
}
}
&--full {
opacity: 0.6;
}
}
.day__name {
font-weight: var(--font-weight-semibold);
text-transform: capitalize;
}
.day__free {
font-size: var(--font-size-xs);
color: var(--color-ink-muted);
}
.slots legend {
font-size: var(--font-size-md);
}
.slot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
gap: var(--space-2);
}
.slot {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: var(--space-2);
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
cursor: pointer;
input {
grid-row: span 2;
width: 1.1rem;
height: 1.1rem;
margin: 0;
accent-color: var(--color-primary);
}
&:hover {
border-color: var(--color-primary);
}
&--checked {
border-color: var(--color-primary);
background: var(--color-primary-soft);
box-shadow: 0 0 0 1px var(--color-primary);
}
&--full {
opacity: 0.55;
cursor: not-allowed;
}
&:has(input:focus-visible) {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
}
.slot__time {
font-weight: var(--font-weight-semibold);
}
.slot__free {
font-size: var(--font-size-xs);
color: var(--color-ink-muted);
}
#slot-error:focus {
outline: none;
}
textarea {
resize: vertical;
}
.form-field select {
max-width: 8rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.summary {
margin: 0;
}
+281
View File
@@ -0,0 +1,281 @@
import type { ShowroomBookingDto, ShowroomSlotDto, ShowroomSlotsDto } from '@iti/shared'
import { DOCUMENT, NgOptimizedImage } from '@angular/common'
import { afterNextRender, ChangeDetectionStrategy, Component, computed, inject, Injector, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { SHOWROOM_PAGE } from '../../content/showroom'
import { ApiError, ApiService } from '../core/api.service'
import { FormGuard, type FormTicket } from '../core/form-guard'
import { SeoService } from '../core/seo.service'
import { SessionService } from '../core/session.service'
import {
addDays,
firstAvailableDay,
formatDay,
formatTime,
formatVisit,
freeSlots,
peopleOptions,
SHOWROOM_PAGE_DAYS,
} from '../core/showroom'
import { COMPANY } from '../core/site'
import { ConsentFields } from '../shared/consent-fields'
import { FormField } from '../shared/form-field'
import { Honeypot } from '../shared/honeypot'
import { PageHeader } from '../shared/page-header'
import { PlaceholderBadge } from '../shared/placeholder-badge'
const PHONE_PATTERN = /^\+?[\d\s./-]{5,30}$/
type SlotsState = 'loading' | 'ready' | 'error'
/**
* /showroom: descrizione e foto (src/content/showroom.ts), orari e prenotazione della visita.
*
* - Calendario da `GET /showroom/slots` (14 giorni per pagina, solo i giorni aperti). Si carica solo nel browser,
* perché la disponibilità cambia di continuo (`no-store`); il server renderizza testi e foto.
* - Giorni come pulsanti (`aria-pressed`), orari come radio; gli slot completi sono disabilitati.
* - `POST /showroom/bookings` con anti-spam (core/form-guard.ts): 201 → prenotazione confermata in pagina e email
* di conferma inviata dall'API; 409 `SLOT_FULL` / 400 `INVALID_SLOT` → calendario ricaricato e scelta da rifare.
*/
@Component({
selector: 'app-showroom-page',
imports: [ReactiveFormsModule, RouterLink, NgOptimizedImage, PageHeader, PlaceholderBadge, FormField, ConsentFields, Honeypot],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './showroom.html',
styleUrl: './showroom.scss',
})
export default class ShowroomPage {
private readonly api = inject(ApiService)
private readonly guard = inject(FormGuard)
private readonly session = inject(SessionService)
private readonly document = inject(DOCUMENT)
private readonly injector = inject(Injector)
protected readonly content = SHOWROOM_PAGE
protected readonly company = COMPANY
protected readonly formatDay = formatDay
protected readonly freeSlots = freeSlots
// --- Calendario ---
protected readonly slotsState = signal<SlotsState>('loading')
protected readonly data = signal<ShowroomSlotsDto | null>(null)
/** Primo giorno della pagina di calendario (null = da oggi). */
private readonly from = signal<string | null>(null)
private readonly history: (string | null)[] = []
protected readonly canGoBack = signal(false)
protected readonly selectedDate = signal<string | null>(null)
protected readonly selectedStart = signal<string | null>(null)
protected readonly selectedDay = computed(() => this.data()?.days.find((d) => d.date === this.selectedDate()) ?? null)
protected readonly selectedSlot = computed<ShowroomSlotDto | null>(
() => this.selectedDay()?.slots.find((s) => s.start === this.selectedStart()) ?? null,
)
protected readonly people = computed(() => peopleOptions(this.selectedSlot(), this.data()?.maxPeoplePerBooking ?? 4))
protected readonly timeZone = computed(() => this.data()?.timeZone ?? 'Europe/Rome')
// --- Form ---
protected readonly form = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(120)] }),
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email, Validators.maxLength(254)] }),
phone: new FormControl('', { nonNullable: true, validators: [Validators.pattern(PHONE_PATTERN)] }),
people: new FormControl(1, { nonNullable: true }),
notes: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(1000)] }),
acceptPrivacy: new FormControl(false, { nonNullable: true }),
acceptMarketing: new FormControl(false, { nonNullable: true }),
})
protected readonly honeypot = signal('')
protected readonly submitted = signal(false)
protected readonly slotError = signal<string | null>(null)
protected readonly error = signal<string | null>(null)
protected readonly busy = signal(false)
protected readonly booking = signal<ShowroomBookingDto | null>(null)
/** Richiesta accettata senza dettagli (risposta 200 dell'API). */
protected readonly received = signal(false)
private ticket: Promise<FormTicket> | null = null
constructor() {
inject(SeoService).setPage({
title: 'Showroom',
description:
'Prenota una visita allo showroom ITI TECHNOLOGY: vedi dal vivo le soluzioni per acqua, energia e comfort con un nostro tecnico.',
path: '/showroom',
})
afterNextRender(() => {
void this.loadSlots()
this.ticket = this.guard.issue()
this.ticket.catch(() => (this.ticket = null))
void this.prefillFromSession()
})
}
// --- Calendario ---
protected async loadSlots(keepSelection = false): Promise<void> {
this.slotsState.set('loading')
try {
const from = this.from()
const data = await firstValueFrom(
this.api.get<ShowroomSlotsDto>('/showroom/slots', { query: { from, days: SHOWROOM_PAGE_DAYS } }),
)
this.data.set(data)
this.slotsState.set('ready')
const stillThere = keepSelection && data.days.some((d) => d.date === this.selectedDate())
if (!stillThere) {
this.selectedDate.set(firstAvailableDay(data))
this.selectedStart.set(null)
} else if (!this.selectedSlot() || this.selectedSlot()!.available === 0) {
this.selectedStart.set(null)
}
} catch {
this.slotsState.set('error')
}
}
/** Pagina successiva: dal giorno dopo l'ultimo mostrato. */
protected nextPage(): void {
const days = this.data()?.days ?? []
const last = days[days.length - 1]?.date ?? addDays(this.from() ?? today(), SHOWROOM_PAGE_DAYS - 1)
this.history.push(this.from())
this.canGoBack.set(true)
this.from.set(addDays(last, 1))
void this.loadSlots()
}
protected previousPage(): void {
if (this.history.length === 0) return
this.from.set(this.history.pop()!)
this.canGoBack.set(this.history.length > 0)
void this.loadSlots()
}
protected selectDay(date: string): void {
this.selectedDate.set(date)
this.selectedStart.set(null)
this.slotError.set(null)
}
protected selectSlot(start: string): void {
this.selectedStart.set(start)
this.slotError.set(null)
// Persone entro i posti liberi dello slot
const max = this.people().length
if (this.form.controls.people.value > max) this.form.controls.people.setValue(max)
}
protected time(iso: string): string {
return formatTime(iso, this.timeZone())
}
protected visit(iso: string): string {
return formatVisit(iso, this.timeZone())
}
// --- Prenotazione ---
private async prefillFromSession(): Promise<void> {
await this.session.ready()
const user = this.session.user()
if (!user) return
const { name, email } = this.form.controls
if (!name.value) name.setValue(`${user.firstName} ${user.lastName}`.trim())
if (!email.value) email.setValue(user.email)
}
protected async submit(): Promise<void> {
if (this.busy()) return
this.submitted.set(true)
this.error.set(null)
const slot = this.selectedSlot()
this.slotError.set(slot ? null : 'Scegli giorno e orario della visita.')
const v = this.form.getRawValue()
if (!slot) {
this.focusSelector('.day[aria-pressed="true"], .day')
return
}
if (this.form.invalid || !v.acceptPrivacy) {
this.focusSelector('#booking-form [aria-invalid="true"]')
return
}
const phone = v.phone.trim()
const notes = v.notes.trim()
const body = {
name: v.name.trim(),
email: v.email.trim().toLowerCase(),
...(phone ? { phone } : {}),
requestedAt: slot.start,
people: v.people,
...(notes ? { notes } : {}),
acceptPrivacy: true,
acceptMarketing: v.acceptMarketing,
}
this.busy.set(true)
try {
const ticket = await (this.ticket ??= this.guard.issue())
const { response } = await this.guard.submit<ShowroomBookingDto | { message: string }>(
'/showroom/bookings',
body,
ticket,
this.honeypot(),
)
if ('id' in response) this.booking.set(response)
else this.received.set(true)
this.focusSelector('#booking-title')
} catch (err) {
this.ticket = null
if (err instanceof ApiError && (err.code === 'SLOT_FULL' || err.code === 'INVALID_SLOT')) {
this.slotError.set(
err.code === 'SLOT_FULL'
? 'Nel frattempo l’orario scelto si è riempito: scegline un altro.'
: 'L’orario scelto non è più prenotabile: scegline un altro.',
)
await this.loadSlots(true)
this.focusSelector('#slot-error')
} else if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
const other = this.applyServerErrors(err.details)
this.error.set(other.length > 0 ? other.join(' ') : 'Controlla i campi evidenziati.')
this.focusSelector('#booking-form [aria-invalid="true"]')
} else if (err instanceof ApiError && err.code === 'RATE_LIMITED') {
this.error.set('Hai inviato troppe richieste in poco tempo. Riprova tra qualche minuto.')
} else if (err instanceof ApiError && err.code === 'NETWORK_ERROR') {
this.error.set('Servizio non raggiungibile. Controlla la connessione e riprova.')
} else {
this.error.set(err instanceof ApiError && err.status < 500 ? err.message : 'Si è verificato un errore. Riprova tra qualche minuto.')
}
} finally {
this.busy.set(false)
}
}
private applyServerErrors(details: { path: string; message: string }[]): string[] {
const other: string[] = []
const c = this.form.controls
for (const d of details) {
const field = d.path.replace(/^body\./, '')
if (field === 'name' || field === 'email' || field === 'phone' || field === 'notes') c[field].setErrors({ server: d.message })
else other.push(d.message)
}
return other
}
protected newBooking(): void {
const { name, email, phone } = this.form.getRawValue()
this.form.reset({ name, email, phone, people: 1, notes: '', acceptPrivacy: false, acceptMarketing: false })
this.submitted.set(false)
this.booking.set(null)
this.received.set(false)
this.ticket = this.guard.issue()
void this.loadSlots()
this.focusSelector('#booking-title')
}
private focusSelector(selector: string): void {
afterNextRender(() => this.document.querySelector<HTMLElement>(selector)?.focus(), { injector: this.injector })
}
}
/** Data di oggi nel fuso dello showroom. */
function today(): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(new Date())
}
+108
View File
@@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { findArea } from '../../content/areas'
import { solutionsOf } from '../../content/catalog'
import { SITE_CONFIG } from '../core/app-config'
import { SeoService } from '../core/seo.service'
import { breadcrumbJsonLd, faqJsonLd } from '../core/structured-data'
import { PageHeader } from '../shared/page-header'
import { PlaceholderBadge } from '../shared/placeholder-badge'
import { SolutionBody } from '../shared/solution-body'
/** /soluzioni/:area, generata da src/content/areas/<area>.ts + le soluzioni dell'area. Aree sconosciute → 404 (canMatch). */
@Component({
selector: 'app-solution-area-page',
imports: [RouterLink, PageHeader, PlaceholderBadge, SolutionBody],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (data(); as a) {
<app-page-header [heading]="a.title" [lead]="a.tagline" [crumbs]="[{ label: a.label }]" [area]="a.slug">
<app-placeholder-badge badge [show]="a.placeholder" />
</app-page-header>
<div class="container body">
<app-solution-body [content]="a">
<section aria-labelledby="solutions-title">
<h2 id="solutions-title">Le soluzioni</h2>
<ul class="solutions">
@for (s of solutions(); track s.slug) {
<li>
<a [routerLink]="['/soluzioni', a.slug, s.slug]">
<span class="title">{{ s.title }}</span>
<span class="text">{{ s.tagline }}</span>
</a>
</li>
}
</ul>
</section>
</app-solution-body>
</div>
}
`,
styles: `
@use 'mixins' as mq;
.body {
padding-block: var(--space-6) var(--space-4);
}
section {
margin-block: var(--space-7);
}
h2 {
font-size: var(--font-size-2xl);
}
.solutions {
display: grid;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
a {
display: flex;
flex-direction: column;
gap: var(--space-2);
height: 100%;
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
color: var(--color-ink);
text-decoration: none;
box-shadow: var(--shadow-sm);
&:hover {
box-shadow: var(--shadow-md);
}
}
}
.title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
text-decoration: underline;
}
.text {
color: var(--color-ink-muted);
}
`,
})
export default class SolutionAreaPage {
readonly area = input.required<string>()
protected readonly data = computed(() => findArea(this.area()))
protected readonly solutions = computed(() => solutionsOf(this.area()))
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
effect(() => {
const a = this.data()
if (!a) return
const path = `/soluzioni/${a.slug}`
seo.setPage({ title: a.title, description: a.description, path, image: a.image.src })
seo.setJsonLd('breadcrumb', breadcrumbJsonLd(siteUrl, [{ name: a.label, path }]))
if (a.faq.length) seo.setJsonLd('faq', faqJsonLd(a.faq))
})
}
}
+99
View File
@@ -0,0 +1,99 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { findSolution, solutionsOf } from '../../content/catalog'
import { SITE_CONFIG } from '../core/app-config'
import { SeoService } from '../core/seo.service'
import { breadcrumbJsonLd, faqJsonLd } from '../core/structured-data'
import { PageHeader } from '../shared/page-header'
import { PlaceholderBadge } from '../shared/placeholder-badge'
import { SolutionBody } from '../shared/solution-body'
/**
* /soluzioni/:area/:soluzione, generata da src/content/solutions/<area>/<soluzione>.ts.
* Coppie sconosciute → 404 (canMatch).
*/
@Component({
selector: 'app-solution-page',
imports: [RouterLink, PageHeader, PlaceholderBadge, SolutionBody],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (data(); as d) {
<app-page-header
[heading]="d.solution.title"
[lead]="d.solution.tagline"
[crumbs]="[{ label: d.area.label, path: '/soluzioni/' + d.area.slug }, { label: d.solution.title }]"
[area]="d.area.slug"
>
<app-placeholder-badge badge [show]="d.solution.placeholder" />
</app-page-header>
<div class="container body">
<app-solution-body [content]="d.solution" />
@if (siblings().length) {
<nav class="siblings" aria-labelledby="siblings-title">
<h2 id="siblings-title">Altre soluzioni {{ d.area.label }}</h2>
<ul>
@for (s of siblings(); track s.slug) {
<li><a [routerLink]="['/soluzioni', d.area.slug, s.slug]">{{ s.title }}</a></li>
}
</ul>
</nav>
}
</div>
}
`,
styles: `
.body {
padding-block: var(--space-6) var(--space-4);
}
.siblings {
margin-block: var(--space-7);
h2 {
font-size: var(--font-size-xl);
}
ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
text-decoration: none;
}
}
`,
})
export default class SolutionPage {
readonly area = input.required<string>()
readonly soluzione = input.required<string>()
protected readonly data = computed(() => findSolution(this.area(), this.soluzione()))
protected readonly siblings = computed(() => solutionsOf(this.area()).filter((s) => s.slug !== this.soluzione()))
constructor() {
const seo = inject(SeoService)
const { siteUrl } = inject(SITE_CONFIG)
effect(() => {
const d = this.data()
if (!d) return
const areaPath = `/soluzioni/${d.area.slug}`
const path = `${areaPath}/${d.solution.slug}`
seo.setPage({ title: `${d.solution.title} - ${d.area.label}`, description: d.solution.description, path, image: d.solution.image.src })
seo.setJsonLd(
'breadcrumb',
breadcrumbJsonLd(siteUrl, [
{ name: d.area.label, path: areaPath },
{ name: d.solution.title, path },
]),
)
if (d.solution.faq.length) seo.setJsonLd('faq', faqJsonLd(d.solution.faq))
})
}
}
@@ -0,0 +1,88 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import type { AddressForm } from '../core/checkout'
import { FormField } from './form-field'
/** Campi di un indirizzo italiano (spedizione o fatturazione) del checkout. */
@Component({
selector: 'app-address-fields',
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@let c = group().controls;
<div class="form-grid">
<app-form-field [control]="c.firstName" label="Nome" [autocomplete]="ac('given-name')" [submitted]="submitted()" />
<app-form-field [control]="c.lastName" label="Cognome" [autocomplete]="ac('family-name')" [submitted]="submitted()" />
<app-form-field
class="span-2"
[control]="c.company"
label="Presso / azienda"
[required]="false"
[autocomplete]="ac('organization')"
[submitted]="submitted()"
/>
<app-form-field
class="span-2"
[control]="c.address1"
label="Indirizzo e numero civico"
[autocomplete]="ac('address-line1')"
[submitted]="submitted()"
/>
<app-form-field
class="span-2"
[control]="c.address2"
label="Scala, interno, citofono"
[required]="false"
[autocomplete]="ac('address-line2')"
[submitted]="submitted()"
/>
<app-form-field
[control]="c.postalCode"
label="CAP"
inputmode="numeric"
[maxlength]="5"
[autocomplete]="ac('postal-code')"
[messages]="{ pattern: 'Il CAP è di 5 cifre' }"
[submitted]="submitted()"
/>
<app-form-field [control]="c.city" label="Città" [autocomplete]="ac('address-level2')" [submitted]="submitted()" />
<app-form-field
[control]="c.province"
label="Provincia (sigla)"
hint="Es. MI, RM, TO"
[maxlength]="2"
[autocomplete]="ac('address-level1')"
[messages]="{ pattern: 'Indica la sigla di 2 lettere' }"
[submitted]="submitted()"
/>
<app-form-field
[control]="c.phone"
label="Telefono"
type="tel"
[required]="phoneRequired()"
[hint]="phoneRequired() ? 'Per il corriere, in caso di necessità' : null"
[autocomplete]="ac('tel')"
[messages]="{ pattern: 'Numero di telefono non valido' }"
[submitted]="submitted()"
/>
</div>
<p class="country">Spediamo solo in Italia.</p>
`,
styles: `
.country {
margin: var(--space-2) 0 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
`,
})
export class AddressFields {
readonly group = input.required<AddressForm>()
/** Sezione per l'autocompletamento del browser: "shipping" o "billing". */
readonly section = input.required<'shipping' | 'billing'>()
readonly phoneRequired = input(true)
readonly submitted = input(false)
protected ac(token: string): string {
return `${this.section()} ${token}`
}
}
@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { type FormControl, ReactiveFormsModule } from '@angular/forms'
let nextId = 0
/**
* Consensi dei form con dati personali: privacy obbligatoria (con link all'informativa) e marketing facoltativo,
* separati. L'errore della privacy compare dopo un tentativo di invio (`submitted`).
*/
@Component({
selector: 'app-consent-fields',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<fieldset class="consents">
<legend class="visually-hidden">Consensi</legend>
<label class="check">
<input
type="checkbox"
[formControl]="privacy()"
required
[attr.aria-invalid]="showError() ? 'true' : null"
[attr.aria-describedby]="showError() ? id + '-privacy-error' : null"
/>
<span>
Ho letto l'<a href="/privacy" target="_blank">informativa sulla privacy</a> e acconsento al trattamento dei miei
dati per {{ purpose() }} (obbligatorio)
</span>
</label>
@if (showError()) {
<p class="field-error" [id]="id + '-privacy-error'">Il consenso è necessario per inviare la richiesta.</p>
}
<label class="check">
<input type="checkbox" [formControl]="marketing()" />
<span>Voglio ricevere novità e offerte via email (facoltativo, revocabile in ogni momento)</span>
</label>
</fieldset>
`,
styles: `
.consents {
margin-top: var(--space-4);
}
`,
})
export class ConsentFields {
readonly privacy = input.required<FormControl<boolean>>()
readonly marketing = input.required<FormControl<boolean>>()
/** "rispondere alla mia richiesta", "gestire la prenotazione"… */
readonly purpose = input.required<string>()
/** Errore visibile (dopo un tentativo di invio senza consenso). */
readonly showError = input(false)
protected readonly id = `consent-${++nextId}`
}
+65
View File
@@ -0,0 +1,65 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import type { Faq } from '../../content/types'
/**
* Domande frequenti come elenco espandibile (<details>: accessibile e senza JavaScript).
* Il JSON-LD FAQPage lo imposta la pagina (faqJsonLd + SeoService.setJsonLd), con le stesse domande.
*/
@Component({
selector: 'app-faq-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (items().length) {
<section class="faq" aria-labelledby="faq-title">
<h2 id="faq-title">Domande frequenti</h2>
@for (item of items(); track item.question) {
<details>
<summary>{{ item.question }}</summary>
<p>{{ item.answer }}</p>
</details>
}
</section>
}
`,
styles: `
.faq {
margin-block: var(--space-7);
}
h2 {
font-size: var(--font-size-2xl);
}
details {
border-bottom: 1px solid var(--color-line);
}
summary {
display: flex;
justify-content: space-between;
gap: var(--space-4);
min-height: 44px;
padding-block: var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::after {
content: '+';
font-size: var(--font-size-xl);
line-height: 1;
color: var(--color-primary);
}
}
details[open] summary::after {
content: '−';
}
p {
max-width: 70ch;
color: var(--color-ink-muted);
}
`,
})
export class FaqList {
readonly items = input.required<readonly Faq[]>()
}
+58
View File
@@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { type Params, RouterLink } from '@angular/router'
export interface FilterLink {
label: string
path: string
active: boolean
}
/** Filtri a "pillole" fatti di link (categorie di blog e shop): funzionano anche lato server e senza JavaScript. */
@Component({
selector: 'app-filter-nav',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav [attr.aria-label]="label()">
<ul>
@for (f of links(); track f.path) {
<li>
<a [routerLink]="f.path" [queryParams]="queryParams()" [attr.aria-current]="f.active ? 'page' : null">{{ f.label }}</a>
</li>
}
</ul>
</nav>
`,
styles: `
ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-inline: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
color: var(--color-ink);
text-decoration: none;
&[aria-current='page'] {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary);
}
}
`,
})
export class FilterNav {
readonly links = input.required<FilterLink[]>()
readonly label = input.required<string>()
/** Query params conservati cambiando filtro (es. la ricerca). */
readonly queryParams = input<Params | null>(null)
}
+97
View File
@@ -0,0 +1,97 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, signal } from '@angular/core'
import { type AbstractControl, FormControl, ReactiveFormsModule, type ValidationErrors } from '@angular/forms'
let nextId = 0
/** Messaggio in italiano per il primo errore del controllo. */
export function fieldErrorMessage(errors: ValidationErrors | null, custom: Record<string, string> = {}): string | null {
if (!errors) return null
const [key, value] = Object.entries(errors)[0] ?? []
if (!key) return null
if (custom[key]) return custom[key]
switch (key) {
case 'required':
return 'Campo obbligatorio'
case 'email':
return 'Indirizzo email non valido'
case 'maxlength':
return `Massimo ${(value as { requiredLength: number }).requiredLength} caratteri`
case 'server':
return String(value)
default:
return 'Valore non valido'
}
}
/**
* Campo di un form reattivo: etichetta, input, suggerimento ed errore collegati con aria-describedby.
* L'errore compare dopo che il campo è stato lasciato (touched) o dopo un tentativo di invio (`submitted`).
* Gli errori arrivati dall'API vanno impostati come `{ server: messaggio }`.
*/
@Component({
selector: 'app-form-field',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label [for]="id">
{{ label() }}
@if (!required()) {
<span class="optional">(facoltativo)</span>
}
</label>
@if (hint()) {
<p class="hint" [id]="id + '-hint'">{{ hint() }}</p>
}
<input
[id]="id"
[type]="type()"
[formControl]="control()"
[attr.autocomplete]="autocomplete()"
[attr.inputmode]="inputmode()"
[attr.maxlength]="maxlength()"
[attr.required]="required() ? '' : null"
[attr.aria-invalid]="error() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
/>
@if (error(); as message) {
<p class="error" [id]="id + '-error'">{{ message }}</p>
}
`,
host: { class: 'form-field' },
})
export class FormField {
readonly control = input.required<FormControl<string>>()
readonly label = input.required<string>()
readonly type = input<'text' | 'email' | 'tel'>('text')
readonly autocomplete = input<string | null>(null)
readonly inputmode = input<string | null>(null)
readonly maxlength = input<number | null>(null)
readonly hint = input<string | null>(null)
readonly required = input(true)
readonly submitted = input(false)
/** Messaggi per errori specifici (es. `{ pattern: 'CAP di 5 cifre' }`). */
readonly messages = input<Record<string, string>>({})
protected readonly id = `field-${++nextId}`
/** Incrementato a ogni evento del controllo (valore, stato, touched): rende reattivo l'errore. */
private readonly version = signal(0)
protected readonly error = computed(() => {
this.version()
const control: AbstractControl = this.control()
if (!control.invalid || !(control.touched || this.submitted())) return null
return fieldErrorMessage(control.errors, this.messages())
})
protected readonly describedBy = computed(() => {
const ids = [this.hint() ? `${this.id}-hint` : null, this.error() ? `${this.id}-error` : null].filter(Boolean)
return ids.length > 0 ? ids.join(' ') : null
})
constructor() {
effect((onCleanup) => {
const sub = this.control().events.subscribe(() => this.version.update((v) => v + 1))
onCleanup(() => sub.unsubscribe())
})
}
}
+31
View File
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, model } from '@angular/core'
/**
* Campo honeypot anti-spam (`website`): fuori schermo, escluso dalla tabulazione, dall'autocompletamento e dagli
* screen reader. Un utente non lo vede né lo compila; i bot che riempiono tutti i campi sì, e l'API scarta l'invio.
* Non usa `type="hidden"`, che molti bot riconoscono e saltano.
*/
@Component({
selector: 'app-honeypot',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="hp" aria-hidden="true">
<label>
Sito web (non compilare)
<input #hp type="text" name="website" tabindex="-1" autocomplete="off" [value]="value()" (input)="value.set(hp.value)" />
</label>
</div>
`,
styles: `
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
`,
})
export class Honeypot {
readonly value = model('')
}
@@ -0,0 +1,81 @@
import type { BlogPostSummaryDto, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { catchError, map, of } from 'rxjs'
import { ApiService } from '../core/api.service'
import { PostCard } from './post-card'
/**
* Ultimi articoli del blog (GET /blog/posts?limit=n). Senza articoli o con l'API giù la sezione non compare.
*/
@Component({
selector: 'app-latest-posts',
imports: [RouterLink, PostCard],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (posts().length) {
<section aria-labelledby="posts-title">
<div class="head">
<h2 id="posts-title">{{ heading() }}</h2>
<a routerLink="/blog">Tutti gli articoli</a>
</div>
<ul>
@for (post of posts(); track post.id) {
<li><app-post-card [post]="post" /></li>
}
</ul>
</section>
}
`,
styles: `
@use 'mixins' as mq;
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
h2 {
margin: 0;
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: var(--font-weight-semibold);
}
}
ul {
display: grid;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(3, 1fr);
}
}
`,
})
export class LatestPosts {
readonly limit = input(3)
readonly heading = input('Dal blog')
private readonly api = inject(ApiService)
private readonly loaded = rxResource({
params: () => this.limit(),
stream: ({ params }) =>
this.api.get<Paginated<BlogPostSummaryDto>>('/blog/posts', { query: { limit: params } }).pipe(
map((page) => page.items),
catchError(() => of([] as BlogPostSummaryDto[])),
),
})
protected readonly posts = computed(() => (this.loaded.hasValue() ? (this.loaded.value() ?? []) : []))
}
+4
View File
@@ -0,0 +1,4 @@
// Build ESM di Leaflet (il pacchetto "leaflet" punta alla build UMD/CommonJS): stessi tipi di @types/leaflet
declare module 'leaflet/dist/leaflet-src.esm.js' {
export * from 'leaflet'
}
+71
View File
@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import type { LegalPageContent } from '../../content/legal'
import { formatDate } from '../core/format'
import { PageHeader } from './page-header'
/**
* Pagina legale (privacy, cookie) da src/content/legal.ts. Finché il testo non è validato (`validated: false`)
* mostra in testa l'avviso "DA VALIDARE CON CONSULENTE PRIVACY", anche in produzione. Contenuto extra via ng-content.
*/
@Component({
selector: 'app-legal-page',
imports: [PageHeader],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@let p = page();
<app-page-header [heading]="p.title" [lead]="p.lead" [crumbs]="[{ label: p.title }]" />
<div class="container body">
@if (!p.validated) {
<p class="draft" role="note">
<strong>DA VALIDARE CON CONSULENTE PRIVACY.</strong> Testo provvisorio: non costituisce ancora l'informativa
definitiva.
</p>
}
<p class="updated">Ultimo aggiornamento: {{ date(p.updatedAt) }}</p>
@for (section of p.sections; track section.heading) {
<section>
<h2>{{ section.heading }}</h2>
@for (paragraph of section.paragraphs; track $index) {
<p>{{ paragraph }}</p>
}
@if (section.items?.length) {
<ul>
@for (item of section.items; track $index) {
<li>{{ item }}</li>
}
</ul>
}
</section>
}
<ng-content />
</div>
`,
styles: `
.body {
max-width: 52rem;
padding-block: var(--space-6) var(--space-8);
}
.draft {
padding: var(--space-3) var(--space-4);
border: 2px dashed var(--color-danger);
border-radius: var(--radius-md);
background: #fdecea;
color: var(--color-danger);
}
.updated {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
h2 {
margin-top: var(--space-6);
font-size: var(--font-size-xl);
}
li + li {
margin-top: var(--space-2);
}
`,
})
export class LegalPage {
readonly page = input.required<LegalPageContent>()
protected readonly date = (iso: string) => formatDate(`${iso}T12:00:00Z`)
}
+107
View File
@@ -0,0 +1,107 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, linkedSignal } from '@angular/core'
import { Router } from '@angular/router'
import { listQueryParams, normalizeQuery } from '../core/listing'
const DEBOUNCE_MS = 350
/**
* Campo di ricerca di un elenco (blog, shop): aggiorna `?q=` dell'URL e torna alla prima pagina.
*
* - Senza JavaScript, o prima dell'idratazione, il form fa una GET normale verso `path`: funziona comunque.
* - Digitando, l'URL si aggiorna dopo una pausa di 350 ms, sostituendo la voce di cronologia (niente voce per
* ogni pausa) e senza riportare la pagina in cima (`scroll: 'manual'`). L'invio è una navigazione normale.
* - Il testo nel campo segue l'URL (back/forward, link), ma non sovrascrive ciò che si sta scrivendo quando
* l'URL cambia per effetto della digitazione stessa (es. lo spazio finale tolto dalla normalizzazione).
*/
@Component({
selector: 'app-list-search',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form role="search" method="get" [attr.action]="path()" (submit)="submit($event)">
<label [for]="inputId()">{{ label() }}</label>
<div class="search">
<input
[id]="inputId()"
name="q"
type="search"
autocomplete="off"
[maxLength]="maxLength()"
[value]="text()"
(input)="onInput($event)"
[placeholder]="placeholder()"
/>
<button class="btn btn--primary" type="submit">Cerca</button>
</div>
</form>
`,
styles: `
label {
display: block;
margin-bottom: var(--space-2);
font-weight: var(--font-weight-semibold);
}
.search {
display: flex;
gap: var(--space-2);
max-width: 36rem;
input {
flex: 1;
min-width: 0;
min-height: 44px;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-pill);
background: var(--color-surface);
font: inherit;
}
}
`,
})
export class ListSearch {
/** Percorso dell'elenco (senza query): destinazione del form e della navigazione. */
readonly path = input.required<string>()
/** Ricerca attuale (già normalizzata, dall'URL). */
readonly query = input.required<string>()
/** Pagina attuale: una ricerca uguale a quella in corso da pagina 1 non naviga. */
readonly page = input(1)
readonly label = input('Cerca')
readonly placeholder = input('')
readonly maxLength = input(200)
readonly inputId = input('list-q')
private readonly router = inject(Router)
private timer: ReturnType<typeof setTimeout> | undefined
protected readonly text = linkedSignal<string, string>({
source: this.query,
computation: (q, previous) => (previous && normalizeQuery(previous.value, this.maxLength()) === q ? previous.value : q),
})
constructor() {
inject(DestroyRef).onDestroy(() => clearTimeout(this.timer))
}
protected onInput(event: Event): void {
const value = (event.target as HTMLInputElement).value
this.text.set(value)
clearTimeout(this.timer)
this.timer = setTimeout(() => this.search(value, true), DEBOUNCE_MS)
}
protected submit(event: Event): void {
event.preventDefault()
clearTimeout(this.timer)
this.search(this.text(), false)
}
private search(value: string, typing: boolean): void {
const q = normalizeQuery(value, this.maxLength())
if (q === this.query() && this.page() === 1) return
void this.router.navigate([this.path()], {
queryParams: listQueryParams(q, 1),
replaceUrl: typing,
scroll: typing ? 'manual' : 'after-transition',
})
}
}
+105
View File
@@ -0,0 +1,105 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { NEEDS } from '../../content/needs'
/**
* "Qual è la tua esigenza?": ogni esigenza porta al configuratore con il percorso preselezionato
* (/configuratore?percorso=acqua|fotovoltaico|pompa_calore|azienda). Riutilizzabile in qualunque pagina.
*/
@Component({
selector: 'app-needs-picker',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="needs" [attr.aria-labelledby]="headingId()">
<h2 [id]="headingId()">{{ heading() }}</h2>
@if (lead()) {
<p class="lead">{{ lead() }}</p>
}
<ul>
@for (need of needs; track need.id) {
<li>
<a class="need" [routerLink]="'/configuratore'" [queryParams]="{ percorso: need.path }" [attr.data-area]="need.area">
<span class="title">{{ need.title }}</span>
<span class="text">{{ need.text }}</span>
<span class="go" aria-hidden="true">→</span>
</a>
</li>
}
</ul>
</section>
`,
styles: `
@use 'mixins' as mq;
.needs {
padding: var(--space-6) var(--space-5);
border-radius: var(--radius-lg);
background: var(--color-primary-soft);
@include mq.up(md) {
padding: var(--space-7);
}
}
h2 {
font-size: var(--font-size-2xl);
}
ul {
display: grid;
gap: var(--space-3);
margin: var(--space-5) 0 0;
padding: 0;
list-style: none;
@include mq.up(md) {
grid-template-columns: repeat(2, 1fr);
}
}
.need {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-1);
height: 100%;
padding: var(--space-4) var(--space-7) var(--space-4) var(--space-5);
border-left: 5px solid var(--color-primary);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: var(--shadow-md);
color: var(--color-ink);
}
&[data-area='acqua'] { border-left-color: var(--color-area-acqua); }
&[data-area='energia'] { border-left-color: var(--color-area-energia); }
&[data-area='comfort'] { border-left-color: var(--color-area-comfort); }
&[data-area='industria'] { border-left-color: var(--color-area-industria); }
}
.title {
font-weight: var(--font-weight-semibold);
}
.text {
color: var(--color-ink-muted);
font-size: var(--font-size-sm);
}
.go {
position: absolute;
right: var(--space-5);
top: 50%;
transform: translateY(-50%);
font-size: var(--font-size-xl);
color: var(--color-primary);
}
`,
})
export class NeedsPicker {
readonly heading = input('Qual è la tua esigenza?')
readonly lead = input<string>()
protected readonly needs = NEEDS
/** Id del titolo (aria-labelledby): da cambiare se il componente compare due volte nella stessa pagina. */
readonly headingId = input('needs-title')
}
@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { PagePlaceholder } from './page-placeholder'
/**
* Contenuto della pagina 404. Lo status HTTP e i meta (noindex) li imposta la pagina che lo mostra:
* la route `**` oppure una pagina con dati dall'API (articolo o categoria inesistente).
*/
@Component({
selector: 'app-not-found-view',
imports: [PagePlaceholder, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-placeholder [heading]="heading()" [lead]="lead()" [showNotice]="false">
<p>
Puoi tornare alla <a routerLink="/">home page</a>, leggere gli <a routerLink="/blog">articoli del blog</a> oppure
<a routerLink="/contatti">contattarci</a>.
</p>
</app-page-placeholder>
`,
})
export class NotFoundView {
readonly heading = input('Pagina non trovata')
readonly lead = input('La pagina che cerchi non esiste o è stata spostata.')
}
+81
View File
@@ -0,0 +1,81 @@
import { DOCUMENT } from '@angular/common'
import { afterNextRender, ChangeDetectionStrategy, Component, DestroyRef, type ElementRef, inject, input, viewChild } from '@angular/core'
import type { Map as LeafletMap } from 'leaflet'
/** Stylesheet di Leaflet copiato dal pacchetto negli asset (angular.json → vendor/leaflet), servito dal sito stesso. */
const LEAFLET_CSS = '/vendor/leaflet/leaflet.css'
/**
* Mappa OpenStreetMap con Leaflet. Va caricata solo su richiesta esplicita dell'utente (pulsante "Mostra mappa"
* dentro un `@defer`), così né Leaflet né le tile partono al caricamento della pagina: nessuna richiesta a
* server terzi (e quindi nessun indirizzo IP inviato a OpenStreetMap) senza un clic. Le tile di OpenStreetMap non
* impostano cookie.
*
* - Codice: build ESM di Leaflet (`leaflet-src.esm.js`) importata dinamicamente, nel chunk lazy della mappa.
* - Stili: `<link>` al CSS di Leaflet aggiunto al primo uso (fuori dai bundle e dal budget degli stili).
* - Marcatore `circleMarker` (vettoriale): le icone PNG predefinite di Leaflet non si risolvono col bundler.
* - ⚠️ CSP (prompt 34): `img-src https://tile.openstreetmap.org`.
*/
@Component({
selector: 'app-osm-map',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div #host class="osm-map" role="region" [attr.aria-label]="'Mappa: ' + label()"></div>`,
styles: `
.osm-map {
width: 100%;
height: 100%;
min-height: 320px;
border-radius: var(--radius-lg);
z-index: 0;
}
`,
})
export class OsmMap {
readonly lat = input.required<number>()
readonly lng = input.required<number>()
readonly label = input.required<string>()
readonly zoom = input(15)
private readonly document = inject(DOCUMENT)
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host')
private map: LeafletMap | null = null
private destroyed = false
constructor() {
afterNextRender(() => void this.create())
inject(DestroyRef).onDestroy(() => {
this.destroyed = true
this.map?.remove()
})
}
private async create(): Promise<void> {
const [L] = await Promise.all([import('leaflet/dist/leaflet-src.esm.js'), this.loadStyles()])
if (this.destroyed) return
const position: [number, number] = [this.lat(), this.lng()]
this.map = L.map(this.host().nativeElement, { scrollWheelZoom: false }).setView(position, this.zoom())
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(this.map)
L.circleMarker(position, { radius: 10, color: '#0b5c7a', weight: 3, fillColor: '#0b5c7a', fillOpacity: 0.6 })
.addTo(this.map)
.bindTooltip(this.label(), { permanent: true, direction: 'top', offset: [0, -10] })
}
/** Aggiunge (una volta) il CSS di Leaflet e attende che sia caricato, così la mappa non si disegna senza stili. */
private loadStyles(): Promise<void> {
const existing = this.document.querySelector<HTMLLinkElement>(`link[href="${LEAFLET_CSS}"]`)
if (existing?.dataset['loaded']) return Promise.resolve()
return new Promise((resolve) => {
const link = existing ?? this.document.createElement('link')
link.addEventListener('load', () => ((link.dataset['loaded'] = '1'), resolve()), { once: true })
link.addEventListener('error', () => resolve(), { once: true })
if (!existing) {
link.rel = 'stylesheet'
link.href = LEAFLET_CSS
this.document.head.appendChild(link)
}
})
}
}
+90
View File
@@ -0,0 +1,90 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { RouterLink } from '@angular/router'
export interface Crumb {
label: string
path?: string
}
/** Intestazione di pagina: breadcrumb (Home + voci), titolo h1, testo introduttivo; contenuto extra via ng-content. */
@Component({
selector: 'app-page-header',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="intro" [attr.data-area]="area()">
<div class="container">
@if (crumbs().length) {
<nav aria-label="Percorso">
<ol class="crumbs">
<li><a routerLink="/">Home</a></li>
@for (c of crumbs(); track c.label) {
<li>
@if (c.path) {
<a [routerLink]="c.path">{{ c.label }}</a>
} @else {
<span aria-current="page">{{ c.label }}</span>
}
</li>
}
</ol>
</nav>
}
<ng-content select="[badge]" />
<h1>{{ heading() }}</h1>
@if (lead()) {
<p class="lead">{{ lead() }}</p>
}
<ng-content />
</div>
</header>
`,
styles: `
@use 'mixins' as mq;
.intro {
padding-block: var(--space-6) var(--space-5);
border-top: 4px solid transparent;
background: linear-gradient(180deg, var(--color-primary-soft), var(--color-surface));
@include mq.up(md) {
padding-block: var(--space-8) var(--space-6);
}
&[data-area='acqua'] { border-top-color: var(--color-area-acqua); }
&[data-area='energia'] { border-top-color: var(--color-area-energia); }
&[data-area='comfort'] { border-top-color: var(--color-area-comfort); }
&[data-area='mobilita'] { border-top-color: var(--color-area-mobilita); }
&[data-area='industria'] { border-top-color: var(--color-area-industria); }
}
.crumbs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
a {
display: inline-block;
padding-block: var(--space-2);
}
li {
display: flex;
align-items: center;
}
li + li::before {
content: '/';
margin-right: var(--space-2);
}
}
`,
})
export class PageHeader {
readonly heading = input.required<string>()
readonly lead = input<string>()
readonly crumbs = input<Crumb[]>([])
/** Slug della macro-area: colora il bordo superiore. */
readonly area = input<string>()
}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { type Crumb, PageHeader } from './page-header'
export type { Crumb } from './page-header'
/**
* Pagina in preparazione: intestazione più un riquadro "in costruzione".
* Usata dalle pagine placeholder finché i prompt successivi non ne implementano il contenuto.
*/
@Component({
selector: 'app-page-placeholder',
imports: [PageHeader],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-header [heading]="heading()" [lead]="lead()" [crumbs]="crumbs()" />
<div class="container body">
<ng-content />
@if (showNotice()) {
<p class="notice">Questa sezione è in preparazione: a breve troverai qui tutti i contenuti.</p>
}
</div>
`,
styles: `
.body {
padding-block: var(--space-6) var(--space-8);
}
.notice {
padding: var(--space-4) var(--space-5);
border-left: 4px solid var(--color-accent);
border-radius: var(--radius-md);
background: var(--color-accent-soft);
}
`,
})
export class PagePlaceholder {
readonly heading = input.required<string>()
readonly lead = input.required<string>()
readonly crumbs = input<Crumb[]>([])
readonly showNotice = input(true)
}
+80
View File
@@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { listQueryParams, pageWindow } from '../core/listing'
/** Paginazione di un elenco: link veri (`?pagina=n`, conserva `?q=`), quindi navigabile anche dai crawler. */
@Component({
selector: 'app-pagination',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (last() > 1) {
<nav class="pagination" [attr.aria-label]="label()">
<ul>
@if (current() > 1) {
<li><a [routerLink]="path()" [queryParams]="query(current() - 1)" rel="prev">‹ Precedente</a></li>
}
@for (p of pages(); track $index) {
<li>
@if (p === null) {
<span class="gap" aria-hidden="true">…</span>
} @else {
<a
[routerLink]="path()"
[queryParams]="query(p)"
[attr.aria-current]="p === current() ? 'page' : null"
[attr.aria-label]="'Pagina ' + p"
>{{ p }}</a
>
}
</li>
}
@if (current() < last()) {
<li><a [routerLink]="path()" [queryParams]="query(current() + 1)" rel="next">Successiva ›</a></li>
}
</ul>
</nav>
}
`,
styles: `
ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-2);
margin: var(--space-7) 0 0;
padding: 0;
list-style: none;
}
a,
.gap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding-inline: var(--space-3);
border-radius: var(--radius-pill);
}
a {
border: 1px solid var(--color-line);
text-decoration: none;
&[aria-current='page'] {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary);
}
}
`,
})
export class Pagination {
readonly path = input.required<string>()
readonly q = input('')
readonly current = input.required<number>()
readonly last = input.required<number>()
readonly label = input('Pagine')
protected readonly pages = computed(() => pageWindow(this.current(), this.last()))
protected readonly query = (page: number) => listQueryParams(this.q(), page)
}
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input, isDevMode } from '@angular/core'
/**
* Etichetta "Testo segnaposto" per i contenuti con `placeholder: true`. Visibile solo in sviluppo
* (isDevMode): serve a ricordare quali testi l'azienda deve ancora fornire; in produzione non compare.
*/
@Component({
selector: 'app-placeholder-badge',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (show() && dev) {
<p class="badge" role="note">Testo segnaposto: da sostituire con i contenuti forniti dall'azienda</p>
}
`,
styles: `
.badge {
display: inline-block;
margin: 0 0 var(--space-4);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-warning);
border-radius: var(--radius-sm);
background: var(--color-accent-soft);
color: var(--color-warning);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
`,
})
export class PlaceholderBadge {
readonly show = input.required<boolean>()
protected readonly dev = isDevMode()
}

Some files were not shown because too many files have changed in this diff Show More