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
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 196 B |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 29 KiB |
@@ -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)' : ''}`,
|
||||
)
|
||||
@@ -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`)
|
||||
@@ -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)
|
||||
@@ -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 })),
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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') },
|
||||
]
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 €')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('; ')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }))
|
||||
}
|
||||
@@ -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 .')).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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }]))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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()))))
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }]))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]>()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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() ?? []) : []))
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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: '© <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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||