From b83ce039fb66887490bd77d87bfe540ef58a0d49 Mon Sep 17 00:00:00 2001 From: Lorenzo Sanesi Date: Tue, 20 Oct 2026 09:30:48 +0200 Subject: [PATCH] Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy 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 Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u --- .dockerignore | 25 + .editorconfig | 12 + .env.example | 23 + .env.prod.example | 76 + .gitattributes | 7 + .gitea/workflows/deploy.yml | 31 + .gitignore | 48 + .nvmrc | 1 + CLAUDE.md | 1436 ++ README.md | 33 + SECURITY_REVIEW.md | 167 + apps/api/.env.example | 91 + apps/api/Dockerfile | 50 + apps/api/drizzle.config.ts | 16 + apps/api/http/http-client.env.json | 7 + .../http/http-client.private.env.example.json | 7 + apps/api/http/shop.http | 147 + apps/api/package.json | 50 + apps/api/src/app.ts | 76 + apps/api/src/config/env.ts | 197 + apps/api/src/config/logger.ts | 57 + apps/api/src/configurator/definition.ts | 224 + apps/api/src/configurator/engine.ts | 400 + apps/api/src/configurator/rules.ts | 276 + apps/api/src/db/client.ts | 20 + apps/api/src/db/migrate.ts | 25 + .../api/src/db/migrations/0000_init_users.sql | 60 + .../db/migrations/0001_immutable_unaccent.sql | 11 + apps/api/src/db/migrations/0002_domain.sql | 205 + .../src/db/migrations/0003_quotes_sent_at.sql | 1 + .../src/db/migrations/meta/0000_snapshot.json | 455 + .../src/db/migrations/meta/0001_snapshot.json | 455 + .../src/db/migrations/meta/0002_snapshot.json | 2030 ++ .../src/db/migrations/meta/0003_snapshot.json | 2036 ++ apps/api/src/db/migrations/meta/_journal.json | 34 + apps/api/src/db/schema/columns.ts | 53 + apps/api/src/db/schema/domain.ts | 331 + apps/api/src/db/schema/index.ts | 4 + apps/api/src/db/schema/users.ts | 90 + apps/api/src/db/seed.ts | 50 + apps/api/src/emails/auth.ts | 44 + apps/api/src/emails/business.ts | 178 + apps/api/src/emails/html.ts | 68 + apps/api/src/emails/index.ts | 78 + apps/api/src/emails/layout.ts | 58 + apps/api/src/emails/orders.ts | 122 + apps/api/src/emails/preview.ts | 93 + apps/api/src/errors.ts | 44 + apps/api/src/plugins/auth.ts | 81 + apps/api/src/plugins/config.ts | 18 + apps/api/src/plugins/db.ts | 25 + apps/api/src/plugins/error-handler.ts | 77 + apps/api/src/plugins/medusa-client.ts | 47 + apps/api/src/plugins/security.ts | 44 + apps/api/src/plugins/shop.ts | 17 + apps/api/src/plugins/storage.ts | 42 + apps/api/src/routes/admin/.gitkeep | 0 apps/api/src/routes/admin/blog.ts | 272 + apps/api/src/routes/admin/contacts.ts | 85 + apps/api/src/routes/admin/documents.ts | 161 + apps/api/src/routes/admin/index.ts | 36 + apps/api/src/routes/admin/installers.ts | 212 + apps/api/src/routes/admin/quotes.ts | 292 + apps/api/src/routes/admin/showroom.ts | 88 + apps/api/src/routes/admin/summary.ts | 55 + apps/api/src/routes/admin/tickets.ts | 118 + apps/api/src/routes/admin/users.ts | 139 + apps/api/src/routes/index.ts | 44 + apps/api/src/routes/private/consents.ts | 122 + apps/api/src/routes/private/installer.ts | 93 + apps/api/src/routes/private/me.ts | 76 + apps/api/src/routes/private/orders.ts | 147 + apps/api/src/routes/private/quotes.ts | 93 + apps/api/src/routes/private/schemas.ts | 62 + apps/api/src/routes/private/tickets.ts | 113 + apps/api/src/routes/public/auth.ts | 423 + apps/api/src/routes/public/blog.ts | 144 + apps/api/src/routes/public/configurator.ts | 156 + apps/api/src/routes/public/contact.ts | 98 + apps/api/src/routes/public/documents.ts | 87 + apps/api/src/routes/public/health.ts | 28 + apps/api/src/routes/public/shop.ts | 391 + apps/api/src/routes/public/showroom.ts | 166 + apps/api/src/routes/webhooks/.gitkeep | 0 apps/api/src/routes/webhooks/medusa.ts | 56 + apps/api/src/server.ts | 35 + apps/api/src/services/auth.ts | 178 + apps/api/src/services/blog.ts | 135 + apps/api/src/services/cart-cookie.ts | 31 + apps/api/src/services/consents.ts | 85 + apps/api/src/services/current-user.ts | 15 + apps/api/src/services/documents.ts | 173 + apps/api/src/services/email.ts | 229 + apps/api/src/services/form-guard.ts | 72 + apps/api/src/services/geo.ts | 48 + apps/api/src/services/installers.ts | 71 + apps/api/src/services/medusa-client.ts | 317 + apps/api/src/services/medusa-sync.ts | 100 + apps/api/src/services/medusa-webhooks.ts | 173 + apps/api/src/services/notifications.ts | 26 + apps/api/src/services/password.ts | 17 + apps/api/src/services/quotes.ts | 85 + apps/api/src/services/shop.ts | 763 + apps/api/src/services/showroom.ts | 78 + apps/api/src/services/storage.ts | 191 + apps/api/src/services/tickets.ts | 41 + apps/api/src/services/tokens.ts | 16 + apps/api/src/services/ttl-cache.ts | 44 + apps/api/src/showroom/config.ts | 56 + apps/api/src/showroom/slots.ts | 131 + apps/api/test/account.test.ts | 167 + apps/api/test/admin.test.ts | 148 + apps/api/test/app.test.ts | 181 + apps/api/test/auth.test.ts | 357 + apps/api/test/blog.test.ts | 227 + apps/api/test/columns.test.ts | 15 + apps/api/test/configurator.test.ts | 240 + apps/api/test/contact.test.ts | 216 + apps/api/test/db-security.test.ts | 44 + apps/api/test/documents.test.ts | 296 + apps/api/test/emails.test.ts | 266 + apps/api/test/global-setup.ts | 14 + apps/api/test/helpers.ts | 163 + apps/api/test/installers.test.ts | 210 + apps/api/test/medusa-mock.ts | 398 + apps/api/test/medusa.test.ts | 245 + apps/api/test/quotes.test.ts | 239 + apps/api/test/shop.test.ts | 324 + apps/api/test/showroom.test.ts | 190 + apps/api/test/tickets.test.ts | 81 + apps/api/test/webhooks.test.ts | 208 + apps/api/tsconfig.build.json | 10 + apps/api/tsconfig.json | 9 + apps/api/vitest.config.ts | 13 + apps/private/angular.json | 85 + apps/private/package.json | 37 + apps/private/public/favicon.ico | Bin 0 -> 196 bytes apps/private/public/favicon.svg | 5 + apps/private/public/logo.svg | 5 + apps/private/scripts/vendor-assets.mjs | 17 + apps/private/src/app/app.config.ts | 23 + apps/private/src/app/app.routes.ts | 106 + apps/private/src/app/app.ts | 33 + apps/private/src/app/core/api.service.ts | 103 + apps/private/src/app/core/app-config.ts | 60 + apps/private/src/app/core/auth.guards.ts | 61 + apps/private/src/app/core/auth.interceptor.ts | 44 + apps/private/src/app/core/auth.spec.ts | 284 + apps/private/src/app/core/auth.store.ts | 233 + apps/private/src/app/core/download.service.ts | 73 + apps/private/src/app/core/format.ts | 50 + .../src/app/core/installer-profile.store.ts | 71 + apps/private/src/app/core/labels.ts | 152 + apps/private/src/app/core/redirect.ts | 48 + apps/private/src/app/core/roles.ts | 69 + apps/private/src/app/layout/auth-layout.ts | 74 + apps/private/src/app/layout/shell.html | 79 + apps/private/src/app/layout/shell.scss | 186 + apps/private/src/app/layout/shell.ts | 67 + .../src/app/pages/admin/admin-common.ts | 44 + .../src/app/pages/admin/admin-dashboard.ts | 202 + .../private/src/app/pages/admin/admin.spec.ts | 292 + .../src/app/pages/admin/blog-categories.ts | 227 + .../src/app/pages/admin/blog-editor.html | 218 + .../src/app/pages/admin/blog-editor.scss | 241 + .../src/app/pages/admin/blog-editor.ts | 330 + apps/private/src/app/pages/admin/blog.ts | 105 + .../src/app/pages/admin/booking-detail.ts | 115 + .../app/pages/admin/configurator-summary.ts | 81 + .../src/app/pages/admin/contact-detail.ts | 113 + apps/private/src/app/pages/admin/contacts.ts | 88 + apps/private/src/app/pages/admin/documents.ts | 344 + .../src/app/pages/admin/installer-detail.ts | 239 + .../private/src/app/pages/admin/installers.ts | 102 + .../src/app/pages/admin/markdown-edit.ts | 92 + .../src/app/pages/admin/quote-detail.ts | 222 + .../private/src/app/pages/admin/quote-form.ts | 124 + .../app/pages/admin/quote-request-detail.ts | 327 + .../src/app/pages/admin/quote-requests.ts | 97 + apps/private/src/app/pages/admin/quotes.ts | 103 + apps/private/src/app/pages/admin/showroom.ts | 114 + .../src/app/pages/admin/status-select.ts | 40 + .../src/app/pages/admin/ticket-detail.ts | 228 + apps/private/src/app/pages/admin/tickets.ts | 116 + apps/private/src/app/pages/admin/upload.ts | 25 + .../src/app/pages/admin/user-detail.ts | 231 + apps/private/src/app/pages/admin/users.ts | 120 + .../src/app/pages/auth/auth-helpers.ts | 30 + .../src/app/pages/auth/forgot-password.ts | 76 + apps/private/src/app/pages/auth/login.ts | 104 + apps/private/src/app/pages/auth/register.html | 126 + apps/private/src/app/pages/auth/register.ts | 182 + .../src/app/pages/auth/reset-password.ts | 109 + .../src/app/pages/auth/verify-email.ts | 86 + .../src/app/pages/customer/customer.spec.ts | 172 + .../src/app/pages/customer/dashboard.ts | 23 + .../src/app/pages/customer/documents.ts | 113 + .../src/app/pages/customer/order-detail.ts | 203 + apps/private/src/app/pages/customer/orders.ts | 82 + .../src/app/pages/customer/overview.ts | 158 + .../src/app/pages/customer/profile.html | 175 + .../private/src/app/pages/customer/profile.ts | 268 + .../src/app/pages/customer/quote-detail.ts | 190 + apps/private/src/app/pages/customer/quotes.ts | 87 + .../src/app/pages/customer/ticket-detail.ts | 162 + .../src/app/pages/customer/ticket-new.ts | 97 + .../private/src/app/pages/customer/tickets.ts | 125 + .../src/app/pages/installer/approval-gate.ts | 59 + .../src/app/pages/installer/installer-home.ts | 179 + .../pages/installer/installer-profile.html | 132 + .../pages/installer/installer-profile.scss | 81 + .../app/pages/installer/installer-profile.ts | 164 + .../src/app/pages/installer/installer.spec.ts | 205 + .../src/app/pages/installer/price-list.ts | 228 + .../src/app/pages/installer/profile-form.ts | 68 + .../src/app/pages/installer/technical-docs.ts | 152 + apps/private/src/app/pages/not-found.ts | 28 + apps/private/src/app/shared/badge.ts | 41 + apps/private/src/app/shared/confirm-dialog.ts | 95 + apps/private/src/app/shared/form-errors.ts | 31 + apps/private/src/app/shared/form-field.ts | 113 + apps/private/src/app/shared/leaflet-esm.d.ts | 4 + apps/private/src/app/shared/list-filters.ts | 128 + .../private/src/app/shared/location-picker.ts | 148 + apps/private/src/app/shared/pager.ts | 51 + apps/private/src/app/shared/state.ts | 103 + apps/private/src/environments/environment.ts | 12 + apps/private/src/index.html | 16 + apps/private/src/main.ts | 8 + apps/private/src/styles.scss | 263 + apps/private/tsconfig.app.json | 8 + apps/private/tsconfig.json | 35 + apps/private/tsconfig.spec.json | 14 + apps/public/.env.example | 23 + apps/public/Dockerfile | 34 + apps/public/angular.json | 95 + apps/public/package.json | 49 + apps/public/public/favicon.ico | Bin 0 -> 196 bytes apps/public/public/favicon.svg | 5 + apps/public/public/images/aree/acqua.svg | 1 + apps/public/public/images/aree/comfort.svg | 1 + apps/public/public/images/aree/energia.svg | 1 + apps/public/public/images/aree/industria.svg | 1 + apps/public/public/images/aree/mobilita.svg | 1 + apps/public/public/images/showroom/acqua.svg | 1 + .../public/public/images/showroom/energia.svg | 1 + apps/public/public/images/showroom/sala.svg | 1 + apps/public/public/logo.svg | 5 + apps/public/public/og-default.png | Bin 0 -> 29797 bytes apps/public/scripts/content-index.mjs | 85 + apps/public/scripts/vendor-assets.mjs | 17 + apps/public/src/app/app.config.server.ts | 33 + apps/public/src/app/app.config.ts | 27 + apps/public/src/app/app.routes.server.ts | 16 + apps/public/src/app/app.routes.ts | 50 + apps/public/src/app/app.ts | 69 + apps/public/src/app/core/api.service.spec.ts | 56 + apps/public/src/app/core/api.service.ts | 103 + apps/public/src/app/core/app-config.ts | 33 + apps/public/src/app/core/auth.interceptor.ts | 37 + apps/public/src/app/core/blog.ts | 14 + apps/public/src/app/core/cart.store.ts | 146 + apps/public/src/app/core/checkout.spec.ts | 195 + apps/public/src/app/core/checkout.ts | 207 + apps/public/src/app/core/configurator.spec.ts | 206 + apps/public/src/app/core/configurator.ts | 246 + apps/public/src/app/core/consent.service.ts | 100 + apps/public/src/app/core/consent.spec.ts | 153 + apps/public/src/app/core/consent.ts | 66 + apps/public/src/app/core/contact.ts | 17 + apps/public/src/app/core/form-guard.ts | 52 + apps/public/src/app/core/format.ts | 31 + .../src/app/core/forwarded-for.interceptor.ts | 15 + apps/public/src/app/core/listing.spec.ts | 81 + apps/public/src/app/core/listing.ts | 71 + .../public/src/app/core/order-confirmation.ts | 39 + apps/public/src/app/core/response-status.ts | 13 + apps/public/src/app/core/seo.service.spec.ts | 75 + apps/public/src/app/core/seo.service.ts | 112 + apps/public/src/app/core/session-cart.spec.ts | 243 + apps/public/src/app/core/session.service.ts | 118 + apps/public/src/app/core/shop.ts | 40 + apps/public/src/app/core/showroom.ts | 57 + apps/public/src/app/core/site.ts | 46 + apps/public/src/app/core/stripe.ts | 35 + apps/public/src/app/core/structured-data.ts | 166 + apps/public/src/app/layout/cookie-banner.ts | 181 + apps/public/src/app/layout/footer.ts | 169 + apps/public/src/app/layout/header.ts | 279 + apps/public/src/app/pages/blog-post.ts | 232 + apps/public/src/app/pages/blog.ts | 239 + apps/public/src/app/pages/cart.ts | 361 + apps/public/src/app/pages/checkout.html | 367 + apps/public/src/app/pages/checkout.scss | 234 + apps/public/src/app/pages/checkout.ts | 481 + apps/public/src/app/pages/company.ts | 139 + .../src/app/pages/configurator-thanks.ts | 71 + apps/public/src/app/pages/configurator.html | 377 + apps/public/src/app/pages/configurator.scss | 302 + apps/public/src/app/pages/configurator.ts | 429 + apps/public/src/app/pages/contact.ts | 406 + apps/public/src/app/pages/cookie.ts | 92 + apps/public/src/app/pages/home.html | 158 + apps/public/src/app/pages/home.scss | 326 + apps/public/src/app/pages/home.ts | 42 + apps/public/src/app/pages/not-found.ts | 22 + apps/public/src/app/pages/order-confirmed.ts | 190 + apps/public/src/app/pages/privacy.ts | 24 + apps/public/src/app/pages/product.ts | 526 + apps/public/src/app/pages/projects.ts | 151 + apps/public/src/app/pages/shop.ts | 249 + apps/public/src/app/pages/showroom.html | 194 + apps/public/src/app/pages/showroom.scss | 219 + apps/public/src/app/pages/showroom.ts | 281 + apps/public/src/app/pages/solution-area.ts | 108 + apps/public/src/app/pages/solution.ts | 99 + apps/public/src/app/shared/address-fields.ts | 88 + apps/public/src/app/shared/consent-fields.ts | 56 + apps/public/src/app/shared/faq-list.ts | 65 + apps/public/src/app/shared/filter-nav.ts | 58 + apps/public/src/app/shared/form-field.ts | 97 + apps/public/src/app/shared/honeypot.ts | 31 + apps/public/src/app/shared/latest-posts.ts | 81 + apps/public/src/app/shared/leaflet-esm.d.ts | 4 + apps/public/src/app/shared/legal-page.ts | 71 + apps/public/src/app/shared/list-search.ts | 107 + apps/public/src/app/shared/needs-picker.ts | 105 + apps/public/src/app/shared/not-found-view.ts | 25 + apps/public/src/app/shared/osm-map.ts | 81 + apps/public/src/app/shared/page-header.ts | 90 + .../public/src/app/shared/page-placeholder.ts | 40 + apps/public/src/app/shared/pagination.ts | 80 + .../src/app/shared/placeholder-badge.ts | 32 + apps/public/src/app/shared/post-card.ts | 88 + apps/public/src/app/shared/product-card.ts | 127 + apps/public/src/app/shared/quote-cta.ts | 79 + .../public/src/app/shared/related-products.ts | 78 + apps/public/src/app/shared/solution-body.ts | 161 + apps/public/src/content/areas.ts | 9 + apps/public/src/content/areas/acqua.ts | 49 + apps/public/src/content/areas/comfort.ts | 48 + apps/public/src/content/areas/energia.ts | 49 + apps/public/src/content/areas/industria.ts | 43 + apps/public/src/content/areas/mobilita.ts | 42 + apps/public/src/content/catalog.ts | 21 + apps/public/src/content/company-pages.ts | 14 + apps/public/src/content/company.ts | 21 + .../src/content/company/certificazioni.ts | 26 + apps/public/src/content/company/chi-siamo.ts | 30 + .../src/content/company/lavora-con-noi.ts | 23 + apps/public/src/content/company/partner.ts | 28 + apps/public/src/content/content.spec.ts | 89 + apps/public/src/content/home.ts | 67 + apps/public/src/content/legal.ts | 128 + apps/public/src/content/needs.ts | 22 + apps/public/src/content/projects.ts | 71 + apps/public/src/content/showroom.ts | 26 + .../content/solutions/acqua/addolcitori.ts | 40 + .../content/solutions/acqua/filtrazione.ts | 36 + .../content/solutions/acqua/osmosi-inversa.ts | 40 + .../solutions/comfort/climatizzazione.ts | 35 + .../solutions/comfort/pompe-di-calore.ts | 41 + .../src/content/solutions/energia/accumulo.ts | 36 + .../content/solutions/energia/fotovoltaico.ts | 41 + .../solutions/industria/acque-di-processo.ts | 36 + .../industria/fotovoltaico-industriale.ts | 37 + .../solutions/mobilita/ricarica-aziendale.ts | 36 + .../src/content/solutions/mobilita/wallbox.ts | 35 + apps/public/src/content/types.ts | 115 + apps/public/src/environments/environment.ts | 17 + apps/public/src/index.html | 16 + apps/public/src/main.server.ts | 8 + apps/public/src/main.ts | 6 + apps/public/src/server.ts | 141 + apps/public/src/server/csp.spec.ts | 29 + apps/public/src/server/csp.ts | 52 + apps/public/src/server/runtime-config.ts | 65 + apps/public/src/server/sitemap.spec.ts | 53 + apps/public/src/server/sitemap.ts | 62 + apps/public/src/styles.scss | 3 + apps/public/tsconfig.app.json | 16 + apps/public/tsconfig.json | 35 + apps/public/tsconfig.spec.json | 14 + docker-compose.dev.yml | 50 + docker-compose.prod.yml | 256 + infra/backup/backup.sh | 44 + infra/backup/restore.sh | 63 + infra/caddy/Caddyfile | 178 + infra/caddy/Dockerfile | 31 + infra/caddy/sites/n8n.caddy.example | 7 + infra/postgres/Dockerfile | 10 + infra/postgres/init/01-init.sql | 43 + medusa/.dockerignore | 12 + medusa/.gitignore | 57 + medusa/AGENTS.md | 165 + medusa/CLAUDE.md | 3 + medusa/Dockerfile | 37 + medusa/LICENSE | 21 + medusa/README.md | 158 + medusa/apps/backend/.env.example | 40 + medusa/apps/backend/.gitignore | 28 + medusa/apps/backend/README.md | 62 + medusa/apps/backend/eslint.config.ts | 4 + medusa/apps/backend/http/http-client.env.json | 5 + .../http/http-client.private.env.example.json | 8 + medusa/apps/backend/http/internal.http | 183 + medusa/apps/backend/http/webhook-receiver.mjs | 41 + medusa/apps/backend/instrumentation.ts | 24 + .../apps/backend/integration-tests/setup.js | 3 + medusa/apps/backend/jest.config.js | 27 + medusa/apps/backend/medusa-config.ts | 117 + medusa/apps/backend/package.json | 60 + medusa/apps/backend/src/admin/README.md | 33 + medusa/apps/backend/src/admin/i18n/README.md | 58 + medusa/apps/backend/src/admin/i18n/index.ts | 1 + medusa/apps/backend/src/admin/tsconfig.json | 27 + medusa/apps/backend/src/admin/vite-env.d.ts | 1 + medusa/apps/backend/src/api/README.md | 135 + .../backend/src/api/admin/custom/route.ts | 8 + .../api/internal/carts/[id]/customer/route.ts | 39 + .../api/internal/carts/[id]/guest/route.ts | 19 + .../internal/customers/[id]/groups/route.ts | 22 + .../internal/customers/[id]/orders/route.ts | 66 + .../src/api/internal/customers/route.ts | 19 + .../src/api/internal/orders/[id]/route.ts | 58 + .../src/api/internal/orders/claim/route.ts | 71 + .../backend/src/api/internal/prices/route.ts | 59 + .../backend/src/api/internal/validators.ts | 42 + medusa/apps/backend/src/api/middlewares.ts | 67 + .../backend/src/api/store/custom/route.ts | 8 + medusa/apps/backend/src/jobs/README.md | 38 + medusa/apps/backend/src/links/README.md | 26 + .../backend/src/migration-scripts/.gitkeep | 0 medusa/apps/backend/src/modules/README.md | 117 + medusa/apps/backend/src/scripts/.gitkeep | 0 medusa/apps/backend/src/scripts/seed-base.ts | 346 + .../backend/src/scripts/seed-installatori.ts | 138 + .../src/search/helpers/option-values.ts | 29 + .../backend/src/search/helpers/pricing.ts | 176 + .../src/search/helpers/resolve-product-ids.ts | 141 + medusa/apps/backend/src/search/product.ts | 165 + medusa/apps/backend/src/subscribers/README.md | 61 + .../src/subscribers/fastify-order-webhooks.ts | 74 + .../apps/backend/src/utils/fastify-webhook.ts | 102 + .../apps/backend/src/utils/internal-secret.ts | 37 + medusa/apps/backend/src/workflows/README.md | 81 + .../workflows/internal/claim-guest-order.ts | 79 + .../internal/set-cart-guest-email.ts | 78 + .../workflows/internal/set-installer-group.ts | 67 + .../internal/upsert-account-customer.ts | 79 + medusa/apps/backend/tsconfig.json | 36 + medusa/eslint.config.ts | 4 + medusa/package-lock.json | 20702 ++++++++++++++++ medusa/package.json | 46 + medusa/turbo.json | 25 + package-lock.json | 10793 ++++++++ package.json | 33 + packages/shared/package.json | 22 + packages/shared/src/configurator.ts | 76 + packages/shared/src/domain.ts | 36 + packages/shared/src/dto.ts | 445 + packages/shared/src/index.ts | 6 + packages/shared/src/user-role.ts | 4 + packages/shared/src/validation.ts | 34 + packages/shared/tsconfig.json | 8 + packages/styles/_base.scss | 300 + packages/styles/_mixins.scss | 35 + packages/styles/_prose.scss | 104 + packages/styles/_tokens.scss | 105 + prompts/00-README.md | 19 + prompts/01-monorepo.md | 20 + prompts/02-docker-dev.md | 19 + prompts/03-medusa-scaffold.md | 20 + prompts/04-medusa-regione-stripe-iva.md | 23 + prompts/05-medusa-installatori.md | 16 + prompts/06-medusa-route-interne.md | 25 + prompts/07-medusa-subscriber-webhook.md | 17 + prompts/08-api-scaffold.md | 22 + prompts/09-api-schema-utenti.md | 22 + prompts/10-api-schema-dominio.md | 22 + prompts/11-api-auth.md | 21 + prompts/12-api-medusa-client.md | 19 + prompts/13-api-proxy-shop.md | 21 + prompts/14-api-webhook-medusa.md | 21 + prompts/15-api-email.md | 22 + prompts/16-api-storage-documenti.md | 22 + prompts/17-api-blog.md | 19 + prompts/18-api-configuratore.md | 20 + prompts/19-api-preventivi-ticket.md | 18 + prompts/20-api-installatori.md | 17 + prompts/21-api-contatti-showroom-consensi.md | 19 + prompts/22-public-scaffold-ssr.md | 21 + prompts/23-public-home-soluzioni.md | 23 + prompts/24-public-blog.md | 17 + prompts/25-public-catalogo-carrello.md | 19 + prompts/26-public-checkout.md | 19 + prompts/27-public-configuratore.md | 20 + prompts/28-public-contatti-showroom-cookie.md | 18 + prompts/29-private-scaffold-auth.md | 20 + prompts/30-private-area-clienti.md | 19 + prompts/31-private-area-installatori.md | 18 + prompts/32-private-admin.md | 22 + prompts/33-produzione-caddy-compose.md | 28 + prompts/34-hardening-review.md | 24 + tsconfig.base.json | 22 + 505 files changed, 84544 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 .gitattributes create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SECURITY_REVIEW.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/http/http-client.env.json create mode 100644 apps/api/http/http-client.private.env.example.json create mode 100644 apps/api/http/shop.http create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/config/logger.ts create mode 100644 apps/api/src/configurator/definition.ts create mode 100644 apps/api/src/configurator/engine.ts create mode 100644 apps/api/src/configurator/rules.ts create mode 100644 apps/api/src/db/client.ts create mode 100644 apps/api/src/db/migrate.ts create mode 100644 apps/api/src/db/migrations/0000_init_users.sql create mode 100644 apps/api/src/db/migrations/0001_immutable_unaccent.sql create mode 100644 apps/api/src/db/migrations/0002_domain.sql create mode 100644 apps/api/src/db/migrations/0003_quotes_sent_at.sql create mode 100644 apps/api/src/db/migrations/meta/0000_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/0001_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/0002_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/0003_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/_journal.json create mode 100644 apps/api/src/db/schema/columns.ts create mode 100644 apps/api/src/db/schema/domain.ts create mode 100644 apps/api/src/db/schema/index.ts create mode 100644 apps/api/src/db/schema/users.ts create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/src/emails/auth.ts create mode 100644 apps/api/src/emails/business.ts create mode 100644 apps/api/src/emails/html.ts create mode 100644 apps/api/src/emails/index.ts create mode 100644 apps/api/src/emails/layout.ts create mode 100644 apps/api/src/emails/orders.ts create mode 100644 apps/api/src/emails/preview.ts create mode 100644 apps/api/src/errors.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/plugins/config.ts create mode 100644 apps/api/src/plugins/db.ts create mode 100644 apps/api/src/plugins/error-handler.ts create mode 100644 apps/api/src/plugins/medusa-client.ts create mode 100644 apps/api/src/plugins/security.ts create mode 100644 apps/api/src/plugins/shop.ts create mode 100644 apps/api/src/plugins/storage.ts create mode 100644 apps/api/src/routes/admin/.gitkeep create mode 100644 apps/api/src/routes/admin/blog.ts create mode 100644 apps/api/src/routes/admin/contacts.ts create mode 100644 apps/api/src/routes/admin/documents.ts create mode 100644 apps/api/src/routes/admin/index.ts create mode 100644 apps/api/src/routes/admin/installers.ts create mode 100644 apps/api/src/routes/admin/quotes.ts create mode 100644 apps/api/src/routes/admin/showroom.ts create mode 100644 apps/api/src/routes/admin/summary.ts create mode 100644 apps/api/src/routes/admin/tickets.ts create mode 100644 apps/api/src/routes/admin/users.ts create mode 100644 apps/api/src/routes/index.ts create mode 100644 apps/api/src/routes/private/consents.ts create mode 100644 apps/api/src/routes/private/installer.ts create mode 100644 apps/api/src/routes/private/me.ts create mode 100644 apps/api/src/routes/private/orders.ts create mode 100644 apps/api/src/routes/private/quotes.ts create mode 100644 apps/api/src/routes/private/schemas.ts create mode 100644 apps/api/src/routes/private/tickets.ts create mode 100644 apps/api/src/routes/public/auth.ts create mode 100644 apps/api/src/routes/public/blog.ts create mode 100644 apps/api/src/routes/public/configurator.ts create mode 100644 apps/api/src/routes/public/contact.ts create mode 100644 apps/api/src/routes/public/documents.ts create mode 100644 apps/api/src/routes/public/health.ts create mode 100644 apps/api/src/routes/public/shop.ts create mode 100644 apps/api/src/routes/public/showroom.ts create mode 100644 apps/api/src/routes/webhooks/.gitkeep create mode 100644 apps/api/src/routes/webhooks/medusa.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/services/auth.ts create mode 100644 apps/api/src/services/blog.ts create mode 100644 apps/api/src/services/cart-cookie.ts create mode 100644 apps/api/src/services/consents.ts create mode 100644 apps/api/src/services/current-user.ts create mode 100644 apps/api/src/services/documents.ts create mode 100644 apps/api/src/services/email.ts create mode 100644 apps/api/src/services/form-guard.ts create mode 100644 apps/api/src/services/geo.ts create mode 100644 apps/api/src/services/installers.ts create mode 100644 apps/api/src/services/medusa-client.ts create mode 100644 apps/api/src/services/medusa-sync.ts create mode 100644 apps/api/src/services/medusa-webhooks.ts create mode 100644 apps/api/src/services/notifications.ts create mode 100644 apps/api/src/services/password.ts create mode 100644 apps/api/src/services/quotes.ts create mode 100644 apps/api/src/services/shop.ts create mode 100644 apps/api/src/services/showroom.ts create mode 100644 apps/api/src/services/storage.ts create mode 100644 apps/api/src/services/tickets.ts create mode 100644 apps/api/src/services/tokens.ts create mode 100644 apps/api/src/services/ttl-cache.ts create mode 100644 apps/api/src/showroom/config.ts create mode 100644 apps/api/src/showroom/slots.ts create mode 100644 apps/api/test/account.test.ts create mode 100644 apps/api/test/admin.test.ts create mode 100644 apps/api/test/app.test.ts create mode 100644 apps/api/test/auth.test.ts create mode 100644 apps/api/test/blog.test.ts create mode 100644 apps/api/test/columns.test.ts create mode 100644 apps/api/test/configurator.test.ts create mode 100644 apps/api/test/contact.test.ts create mode 100644 apps/api/test/db-security.test.ts create mode 100644 apps/api/test/documents.test.ts create mode 100644 apps/api/test/emails.test.ts create mode 100644 apps/api/test/global-setup.ts create mode 100644 apps/api/test/helpers.ts create mode 100644 apps/api/test/installers.test.ts create mode 100644 apps/api/test/medusa-mock.ts create mode 100644 apps/api/test/medusa.test.ts create mode 100644 apps/api/test/quotes.test.ts create mode 100644 apps/api/test/shop.test.ts create mode 100644 apps/api/test/showroom.test.ts create mode 100644 apps/api/test/tickets.test.ts create mode 100644 apps/api/test/webhooks.test.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/private/angular.json create mode 100644 apps/private/package.json create mode 100644 apps/private/public/favicon.ico create mode 100644 apps/private/public/favicon.svg create mode 100644 apps/private/public/logo.svg create mode 100644 apps/private/scripts/vendor-assets.mjs create mode 100644 apps/private/src/app/app.config.ts create mode 100644 apps/private/src/app/app.routes.ts create mode 100644 apps/private/src/app/app.ts create mode 100644 apps/private/src/app/core/api.service.ts create mode 100644 apps/private/src/app/core/app-config.ts create mode 100644 apps/private/src/app/core/auth.guards.ts create mode 100644 apps/private/src/app/core/auth.interceptor.ts create mode 100644 apps/private/src/app/core/auth.spec.ts create mode 100644 apps/private/src/app/core/auth.store.ts create mode 100644 apps/private/src/app/core/download.service.ts create mode 100644 apps/private/src/app/core/format.ts create mode 100644 apps/private/src/app/core/installer-profile.store.ts create mode 100644 apps/private/src/app/core/labels.ts create mode 100644 apps/private/src/app/core/redirect.ts create mode 100644 apps/private/src/app/core/roles.ts create mode 100644 apps/private/src/app/layout/auth-layout.ts create mode 100644 apps/private/src/app/layout/shell.html create mode 100644 apps/private/src/app/layout/shell.scss create mode 100644 apps/private/src/app/layout/shell.ts create mode 100644 apps/private/src/app/pages/admin/admin-common.ts create mode 100644 apps/private/src/app/pages/admin/admin-dashboard.ts create mode 100644 apps/private/src/app/pages/admin/admin.spec.ts create mode 100644 apps/private/src/app/pages/admin/blog-categories.ts create mode 100644 apps/private/src/app/pages/admin/blog-editor.html create mode 100644 apps/private/src/app/pages/admin/blog-editor.scss create mode 100644 apps/private/src/app/pages/admin/blog-editor.ts create mode 100644 apps/private/src/app/pages/admin/blog.ts create mode 100644 apps/private/src/app/pages/admin/booking-detail.ts create mode 100644 apps/private/src/app/pages/admin/configurator-summary.ts create mode 100644 apps/private/src/app/pages/admin/contact-detail.ts create mode 100644 apps/private/src/app/pages/admin/contacts.ts create mode 100644 apps/private/src/app/pages/admin/documents.ts create mode 100644 apps/private/src/app/pages/admin/installer-detail.ts create mode 100644 apps/private/src/app/pages/admin/installers.ts create mode 100644 apps/private/src/app/pages/admin/markdown-edit.ts create mode 100644 apps/private/src/app/pages/admin/quote-detail.ts create mode 100644 apps/private/src/app/pages/admin/quote-form.ts create mode 100644 apps/private/src/app/pages/admin/quote-request-detail.ts create mode 100644 apps/private/src/app/pages/admin/quote-requests.ts create mode 100644 apps/private/src/app/pages/admin/quotes.ts create mode 100644 apps/private/src/app/pages/admin/showroom.ts create mode 100644 apps/private/src/app/pages/admin/status-select.ts create mode 100644 apps/private/src/app/pages/admin/ticket-detail.ts create mode 100644 apps/private/src/app/pages/admin/tickets.ts create mode 100644 apps/private/src/app/pages/admin/upload.ts create mode 100644 apps/private/src/app/pages/admin/user-detail.ts create mode 100644 apps/private/src/app/pages/admin/users.ts create mode 100644 apps/private/src/app/pages/auth/auth-helpers.ts create mode 100644 apps/private/src/app/pages/auth/forgot-password.ts create mode 100644 apps/private/src/app/pages/auth/login.ts create mode 100644 apps/private/src/app/pages/auth/register.html create mode 100644 apps/private/src/app/pages/auth/register.ts create mode 100644 apps/private/src/app/pages/auth/reset-password.ts create mode 100644 apps/private/src/app/pages/auth/verify-email.ts create mode 100644 apps/private/src/app/pages/customer/customer.spec.ts create mode 100644 apps/private/src/app/pages/customer/dashboard.ts create mode 100644 apps/private/src/app/pages/customer/documents.ts create mode 100644 apps/private/src/app/pages/customer/order-detail.ts create mode 100644 apps/private/src/app/pages/customer/orders.ts create mode 100644 apps/private/src/app/pages/customer/overview.ts create mode 100644 apps/private/src/app/pages/customer/profile.html create mode 100644 apps/private/src/app/pages/customer/profile.ts create mode 100644 apps/private/src/app/pages/customer/quote-detail.ts create mode 100644 apps/private/src/app/pages/customer/quotes.ts create mode 100644 apps/private/src/app/pages/customer/ticket-detail.ts create mode 100644 apps/private/src/app/pages/customer/ticket-new.ts create mode 100644 apps/private/src/app/pages/customer/tickets.ts create mode 100644 apps/private/src/app/pages/installer/approval-gate.ts create mode 100644 apps/private/src/app/pages/installer/installer-home.ts create mode 100644 apps/private/src/app/pages/installer/installer-profile.html create mode 100644 apps/private/src/app/pages/installer/installer-profile.scss create mode 100644 apps/private/src/app/pages/installer/installer-profile.ts create mode 100644 apps/private/src/app/pages/installer/installer.spec.ts create mode 100644 apps/private/src/app/pages/installer/price-list.ts create mode 100644 apps/private/src/app/pages/installer/profile-form.ts create mode 100644 apps/private/src/app/pages/installer/technical-docs.ts create mode 100644 apps/private/src/app/pages/not-found.ts create mode 100644 apps/private/src/app/shared/badge.ts create mode 100644 apps/private/src/app/shared/confirm-dialog.ts create mode 100644 apps/private/src/app/shared/form-errors.ts create mode 100644 apps/private/src/app/shared/form-field.ts create mode 100644 apps/private/src/app/shared/leaflet-esm.d.ts create mode 100644 apps/private/src/app/shared/list-filters.ts create mode 100644 apps/private/src/app/shared/location-picker.ts create mode 100644 apps/private/src/app/shared/pager.ts create mode 100644 apps/private/src/app/shared/state.ts create mode 100644 apps/private/src/environments/environment.ts create mode 100644 apps/private/src/index.html create mode 100644 apps/private/src/main.ts create mode 100644 apps/private/src/styles.scss create mode 100644 apps/private/tsconfig.app.json create mode 100644 apps/private/tsconfig.json create mode 100644 apps/private/tsconfig.spec.json create mode 100644 apps/public/.env.example create mode 100644 apps/public/Dockerfile create mode 100644 apps/public/angular.json create mode 100644 apps/public/package.json create mode 100644 apps/public/public/favicon.ico create mode 100644 apps/public/public/favicon.svg create mode 100644 apps/public/public/images/aree/acqua.svg create mode 100644 apps/public/public/images/aree/comfort.svg create mode 100644 apps/public/public/images/aree/energia.svg create mode 100644 apps/public/public/images/aree/industria.svg create mode 100644 apps/public/public/images/aree/mobilita.svg create mode 100644 apps/public/public/images/showroom/acqua.svg create mode 100644 apps/public/public/images/showroom/energia.svg create mode 100644 apps/public/public/images/showroom/sala.svg create mode 100644 apps/public/public/logo.svg create mode 100644 apps/public/public/og-default.png create mode 100644 apps/public/scripts/content-index.mjs create mode 100644 apps/public/scripts/vendor-assets.mjs create mode 100644 apps/public/src/app/app.config.server.ts create mode 100644 apps/public/src/app/app.config.ts create mode 100644 apps/public/src/app/app.routes.server.ts create mode 100644 apps/public/src/app/app.routes.ts create mode 100644 apps/public/src/app/app.ts create mode 100644 apps/public/src/app/core/api.service.spec.ts create mode 100644 apps/public/src/app/core/api.service.ts create mode 100644 apps/public/src/app/core/app-config.ts create mode 100644 apps/public/src/app/core/auth.interceptor.ts create mode 100644 apps/public/src/app/core/blog.ts create mode 100644 apps/public/src/app/core/cart.store.ts create mode 100644 apps/public/src/app/core/checkout.spec.ts create mode 100644 apps/public/src/app/core/checkout.ts create mode 100644 apps/public/src/app/core/configurator.spec.ts create mode 100644 apps/public/src/app/core/configurator.ts create mode 100644 apps/public/src/app/core/consent.service.ts create mode 100644 apps/public/src/app/core/consent.spec.ts create mode 100644 apps/public/src/app/core/consent.ts create mode 100644 apps/public/src/app/core/contact.ts create mode 100644 apps/public/src/app/core/form-guard.ts create mode 100644 apps/public/src/app/core/format.ts create mode 100644 apps/public/src/app/core/forwarded-for.interceptor.ts create mode 100644 apps/public/src/app/core/listing.spec.ts create mode 100644 apps/public/src/app/core/listing.ts create mode 100644 apps/public/src/app/core/order-confirmation.ts create mode 100644 apps/public/src/app/core/response-status.ts create mode 100644 apps/public/src/app/core/seo.service.spec.ts create mode 100644 apps/public/src/app/core/seo.service.ts create mode 100644 apps/public/src/app/core/session-cart.spec.ts create mode 100644 apps/public/src/app/core/session.service.ts create mode 100644 apps/public/src/app/core/shop.ts create mode 100644 apps/public/src/app/core/showroom.ts create mode 100644 apps/public/src/app/core/site.ts create mode 100644 apps/public/src/app/core/stripe.ts create mode 100644 apps/public/src/app/core/structured-data.ts create mode 100644 apps/public/src/app/layout/cookie-banner.ts create mode 100644 apps/public/src/app/layout/footer.ts create mode 100644 apps/public/src/app/layout/header.ts create mode 100644 apps/public/src/app/pages/blog-post.ts create mode 100644 apps/public/src/app/pages/blog.ts create mode 100644 apps/public/src/app/pages/cart.ts create mode 100644 apps/public/src/app/pages/checkout.html create mode 100644 apps/public/src/app/pages/checkout.scss create mode 100644 apps/public/src/app/pages/checkout.ts create mode 100644 apps/public/src/app/pages/company.ts create mode 100644 apps/public/src/app/pages/configurator-thanks.ts create mode 100644 apps/public/src/app/pages/configurator.html create mode 100644 apps/public/src/app/pages/configurator.scss create mode 100644 apps/public/src/app/pages/configurator.ts create mode 100644 apps/public/src/app/pages/contact.ts create mode 100644 apps/public/src/app/pages/cookie.ts create mode 100644 apps/public/src/app/pages/home.html create mode 100644 apps/public/src/app/pages/home.scss create mode 100644 apps/public/src/app/pages/home.ts create mode 100644 apps/public/src/app/pages/not-found.ts create mode 100644 apps/public/src/app/pages/order-confirmed.ts create mode 100644 apps/public/src/app/pages/privacy.ts create mode 100644 apps/public/src/app/pages/product.ts create mode 100644 apps/public/src/app/pages/projects.ts create mode 100644 apps/public/src/app/pages/shop.ts create mode 100644 apps/public/src/app/pages/showroom.html create mode 100644 apps/public/src/app/pages/showroom.scss create mode 100644 apps/public/src/app/pages/showroom.ts create mode 100644 apps/public/src/app/pages/solution-area.ts create mode 100644 apps/public/src/app/pages/solution.ts create mode 100644 apps/public/src/app/shared/address-fields.ts create mode 100644 apps/public/src/app/shared/consent-fields.ts create mode 100644 apps/public/src/app/shared/faq-list.ts create mode 100644 apps/public/src/app/shared/filter-nav.ts create mode 100644 apps/public/src/app/shared/form-field.ts create mode 100644 apps/public/src/app/shared/honeypot.ts create mode 100644 apps/public/src/app/shared/latest-posts.ts create mode 100644 apps/public/src/app/shared/leaflet-esm.d.ts create mode 100644 apps/public/src/app/shared/legal-page.ts create mode 100644 apps/public/src/app/shared/list-search.ts create mode 100644 apps/public/src/app/shared/needs-picker.ts create mode 100644 apps/public/src/app/shared/not-found-view.ts create mode 100644 apps/public/src/app/shared/osm-map.ts create mode 100644 apps/public/src/app/shared/page-header.ts create mode 100644 apps/public/src/app/shared/page-placeholder.ts create mode 100644 apps/public/src/app/shared/pagination.ts create mode 100644 apps/public/src/app/shared/placeholder-badge.ts create mode 100644 apps/public/src/app/shared/post-card.ts create mode 100644 apps/public/src/app/shared/product-card.ts create mode 100644 apps/public/src/app/shared/quote-cta.ts create mode 100644 apps/public/src/app/shared/related-products.ts create mode 100644 apps/public/src/app/shared/solution-body.ts create mode 100644 apps/public/src/content/areas.ts create mode 100644 apps/public/src/content/areas/acqua.ts create mode 100644 apps/public/src/content/areas/comfort.ts create mode 100644 apps/public/src/content/areas/energia.ts create mode 100644 apps/public/src/content/areas/industria.ts create mode 100644 apps/public/src/content/areas/mobilita.ts create mode 100644 apps/public/src/content/catalog.ts create mode 100644 apps/public/src/content/company-pages.ts create mode 100644 apps/public/src/content/company.ts create mode 100644 apps/public/src/content/company/certificazioni.ts create mode 100644 apps/public/src/content/company/chi-siamo.ts create mode 100644 apps/public/src/content/company/lavora-con-noi.ts create mode 100644 apps/public/src/content/company/partner.ts create mode 100644 apps/public/src/content/content.spec.ts create mode 100644 apps/public/src/content/home.ts create mode 100644 apps/public/src/content/legal.ts create mode 100644 apps/public/src/content/needs.ts create mode 100644 apps/public/src/content/projects.ts create mode 100644 apps/public/src/content/showroom.ts create mode 100644 apps/public/src/content/solutions/acqua/addolcitori.ts create mode 100644 apps/public/src/content/solutions/acqua/filtrazione.ts create mode 100644 apps/public/src/content/solutions/acqua/osmosi-inversa.ts create mode 100644 apps/public/src/content/solutions/comfort/climatizzazione.ts create mode 100644 apps/public/src/content/solutions/comfort/pompe-di-calore.ts create mode 100644 apps/public/src/content/solutions/energia/accumulo.ts create mode 100644 apps/public/src/content/solutions/energia/fotovoltaico.ts create mode 100644 apps/public/src/content/solutions/industria/acque-di-processo.ts create mode 100644 apps/public/src/content/solutions/industria/fotovoltaico-industriale.ts create mode 100644 apps/public/src/content/solutions/mobilita/ricarica-aziendale.ts create mode 100644 apps/public/src/content/solutions/mobilita/wallbox.ts create mode 100644 apps/public/src/content/types.ts create mode 100644 apps/public/src/environments/environment.ts create mode 100644 apps/public/src/index.html create mode 100644 apps/public/src/main.server.ts create mode 100644 apps/public/src/main.ts create mode 100644 apps/public/src/server.ts create mode 100644 apps/public/src/server/csp.spec.ts create mode 100644 apps/public/src/server/csp.ts create mode 100644 apps/public/src/server/runtime-config.ts create mode 100644 apps/public/src/server/sitemap.spec.ts create mode 100644 apps/public/src/server/sitemap.ts create mode 100644 apps/public/src/styles.scss create mode 100644 apps/public/tsconfig.app.json create mode 100644 apps/public/tsconfig.json create mode 100644 apps/public/tsconfig.spec.json create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 infra/backup/backup.sh create mode 100644 infra/backup/restore.sh create mode 100644 infra/caddy/Caddyfile create mode 100644 infra/caddy/Dockerfile create mode 100644 infra/caddy/sites/n8n.caddy.example create mode 100644 infra/postgres/Dockerfile create mode 100644 infra/postgres/init/01-init.sql create mode 100644 medusa/.dockerignore create mode 100644 medusa/.gitignore create mode 100644 medusa/AGENTS.md create mode 100644 medusa/CLAUDE.md create mode 100644 medusa/Dockerfile create mode 100644 medusa/LICENSE create mode 100644 medusa/README.md create mode 100644 medusa/apps/backend/.env.example create mode 100644 medusa/apps/backend/.gitignore create mode 100644 medusa/apps/backend/README.md create mode 100644 medusa/apps/backend/eslint.config.ts create mode 100644 medusa/apps/backend/http/http-client.env.json create mode 100644 medusa/apps/backend/http/http-client.private.env.example.json create mode 100644 medusa/apps/backend/http/internal.http create mode 100644 medusa/apps/backend/http/webhook-receiver.mjs create mode 100644 medusa/apps/backend/instrumentation.ts create mode 100644 medusa/apps/backend/integration-tests/setup.js create mode 100644 medusa/apps/backend/jest.config.js create mode 100644 medusa/apps/backend/medusa-config.ts create mode 100644 medusa/apps/backend/package.json create mode 100644 medusa/apps/backend/src/admin/README.md create mode 100644 medusa/apps/backend/src/admin/i18n/README.md create mode 100644 medusa/apps/backend/src/admin/i18n/index.ts create mode 100644 medusa/apps/backend/src/admin/tsconfig.json create mode 100644 medusa/apps/backend/src/admin/vite-env.d.ts create mode 100644 medusa/apps/backend/src/api/README.md create mode 100644 medusa/apps/backend/src/api/admin/custom/route.ts create mode 100644 medusa/apps/backend/src/api/internal/carts/[id]/customer/route.ts create mode 100644 medusa/apps/backend/src/api/internal/carts/[id]/guest/route.ts create mode 100644 medusa/apps/backend/src/api/internal/customers/[id]/groups/route.ts create mode 100644 medusa/apps/backend/src/api/internal/customers/[id]/orders/route.ts create mode 100644 medusa/apps/backend/src/api/internal/customers/route.ts create mode 100644 medusa/apps/backend/src/api/internal/orders/[id]/route.ts create mode 100644 medusa/apps/backend/src/api/internal/orders/claim/route.ts create mode 100644 medusa/apps/backend/src/api/internal/prices/route.ts create mode 100644 medusa/apps/backend/src/api/internal/validators.ts create mode 100644 medusa/apps/backend/src/api/middlewares.ts create mode 100644 medusa/apps/backend/src/api/store/custom/route.ts create mode 100644 medusa/apps/backend/src/jobs/README.md create mode 100644 medusa/apps/backend/src/links/README.md create mode 100644 medusa/apps/backend/src/migration-scripts/.gitkeep create mode 100644 medusa/apps/backend/src/modules/README.md create mode 100644 medusa/apps/backend/src/scripts/.gitkeep create mode 100644 medusa/apps/backend/src/scripts/seed-base.ts create mode 100644 medusa/apps/backend/src/scripts/seed-installatori.ts create mode 100644 medusa/apps/backend/src/search/helpers/option-values.ts create mode 100644 medusa/apps/backend/src/search/helpers/pricing.ts create mode 100644 medusa/apps/backend/src/search/helpers/resolve-product-ids.ts create mode 100644 medusa/apps/backend/src/search/product.ts create mode 100644 medusa/apps/backend/src/subscribers/README.md create mode 100644 medusa/apps/backend/src/subscribers/fastify-order-webhooks.ts create mode 100644 medusa/apps/backend/src/utils/fastify-webhook.ts create mode 100644 medusa/apps/backend/src/utils/internal-secret.ts create mode 100644 medusa/apps/backend/src/workflows/README.md create mode 100644 medusa/apps/backend/src/workflows/internal/claim-guest-order.ts create mode 100644 medusa/apps/backend/src/workflows/internal/set-cart-guest-email.ts create mode 100644 medusa/apps/backend/src/workflows/internal/set-installer-group.ts create mode 100644 medusa/apps/backend/src/workflows/internal/upsert-account-customer.ts create mode 100644 medusa/apps/backend/tsconfig.json create mode 100644 medusa/eslint.config.ts create mode 100644 medusa/package-lock.json create mode 100644 medusa/package.json create mode 100644 medusa/turbo.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/configurator.ts create mode 100644 packages/shared/src/domain.ts create mode 100644 packages/shared/src/dto.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/user-role.ts create mode 100644 packages/shared/src/validation.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/styles/_base.scss create mode 100644 packages/styles/_mixins.scss create mode 100644 packages/styles/_prose.scss create mode 100644 packages/styles/_tokens.scss create mode 100644 prompts/00-README.md create mode 100644 prompts/01-monorepo.md create mode 100644 prompts/02-docker-dev.md create mode 100644 prompts/03-medusa-scaffold.md create mode 100644 prompts/04-medusa-regione-stripe-iva.md create mode 100644 prompts/05-medusa-installatori.md create mode 100644 prompts/06-medusa-route-interne.md create mode 100644 prompts/07-medusa-subscriber-webhook.md create mode 100644 prompts/08-api-scaffold.md create mode 100644 prompts/09-api-schema-utenti.md create mode 100644 prompts/10-api-schema-dominio.md create mode 100644 prompts/11-api-auth.md create mode 100644 prompts/12-api-medusa-client.md create mode 100644 prompts/13-api-proxy-shop.md create mode 100644 prompts/14-api-webhook-medusa.md create mode 100644 prompts/15-api-email.md create mode 100644 prompts/16-api-storage-documenti.md create mode 100644 prompts/17-api-blog.md create mode 100644 prompts/18-api-configuratore.md create mode 100644 prompts/19-api-preventivi-ticket.md create mode 100644 prompts/20-api-installatori.md create mode 100644 prompts/21-api-contatti-showroom-consensi.md create mode 100644 prompts/22-public-scaffold-ssr.md create mode 100644 prompts/23-public-home-soluzioni.md create mode 100644 prompts/24-public-blog.md create mode 100644 prompts/25-public-catalogo-carrello.md create mode 100644 prompts/26-public-checkout.md create mode 100644 prompts/27-public-configuratore.md create mode 100644 prompts/28-public-contatti-showroom-cookie.md create mode 100644 prompts/29-private-scaffold-auth.md create mode 100644 prompts/30-private-area-clienti.md create mode 100644 prompts/31-private-area-installatori.md create mode 100644 prompts/32-private-admin.md create mode 100644 prompts/33-produzione-caddy-compose.md create mode 100644 prompts/34-hardening-review.md create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..32b6702 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Contesto di build delle immagini api, public e caddy (radice del monorepo). +# Medusa ha il suo contesto (medusa/) e il suo .dockerignore. +.git +**/node_modules +**/dist +**/.angular +**/.turbo +**/*.tsbuildinfo +**/.env +**/.env.* +!**/.env.example +**/uploads +**/logs +**/.outbox +**/http +**/*.private.env.json +medusa +backups +prompts +apps/public/src/content/generated +apps/public/public/vendor +apps/private/public/vendor +bash.exe.stackdump +.idea +.vscode diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4bd3bd8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c2a794c --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Variabili d'ambiente del monorepo ITI TECHNOLOGY. +# Copia questo file in `.env` e compila i valori. Non committare mai `.env`. +# Ogni nuova variabile introdotta dai prompt va aggiunta qui (con valore di esempio o vuoto). + +# --- Postgres (superuser del container, solo per init/amministrazione) --- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=change-me +POSTGRES_DB=postgres +POSTGRES_PORT=5432 + +# --- Database applicativo (apps/api) --- +APP_DB_NAME=iti_app +APP_DB_USER=iti_app +APP_DB_PASSWORD=change-me + +# --- Database Medusa --- +MEDUSA_DB_NAME=iti_medusa +MEDUSA_DB_USER=iti_medusa +MEDUSA_DB_PASSWORD=change-me + +# --- Redis --- +REDIS_PORT=6379 +REDIS_URL=redis://localhost:6379 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..95560a8 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,76 @@ +# Produzione (docker-compose.prod.yml). Sulla VPS copiare questo file in `.env` nella root del repo e compilarlo. +# Non committare mai il file compilato. Procedura completa in CLAUDE.md → "Deploy". +# +# Segreti: valori casuali lunghi, SOLO esadecimali (finiscono anche dentro URL postgres://), es. +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# openssl rand -hex 32 + +# --- Dominio e TLS --- +# Dominio principale, senza www né protocollo. Sottodomini usati: www, area, api, shop-admin, media +DOMAIN=example.it +# Email per Let's Encrypt (avvisi di scadenza dei certificati) +ACME_EMAIL=admin@example.it + +# --- Dashboard Medusa (shop-admin.DOMAIN) --- +SHOP_ADMIN_USER=iti +# Hash bcrypt della password di basic auth, TRA APICI SINGOLI (contiene "$"): +# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'password-lunga' +SHOP_ADMIN_PASSWORD_HASH='$2a$14$sostituire-con-hash-bcrypt' +# IP/CIDR ammessi, separati da spazio (vuoto = tutti; la basic auth resta comunque) +SHOP_ADMIN_ALLOWED_IPS= + +# --- Postgres --- +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +POSTGRES_DB=postgres +APP_DB_NAME=iti_app +APP_DB_USER=iti_app +APP_DB_PASSWORD= +MEDUSA_DB_NAME=iti_medusa +MEDUSA_DB_USER=iti_medusa +MEDUSA_DB_PASSWORD= + +# --- Segreti condivisi API ↔ Medusa --- +INTERNAL_API_SECRET= +WEBHOOK_SECRET= + +# --- API (apps/api) --- +API_COOKIE_SECRET= +API_JWT_SECRET= +API_LOG_LEVEL=info +# Stampata da `npm run deploy:seed:medusa` al primo avvio (riga MEDUSA_PUBLISHABLE_KEY=pk_...) +MEDUSA_PUBLISHABLE_KEY= +PRIVACY_POLICY_VERSION=privacy-v1 +# Destinatari delle notifiche interne, separati da virgola (vuoto = utenti admin attivi) +ADMIN_NOTIFY_EMAILS= +# Email transazionali: console (NON invia: solo log) | brevo | resend +EMAIL_PROVIDER=console +EMAIL_FROM= +EMAIL_FROM_NAME=ITI TECHNOLOGY +EMAIL_REPLY_TO= +BREVO_API_KEY= +RESEND_API_KEY= +# Admin iniziale dell'area riservata (`npm run db:seed:prod`), password almeno 12 caratteri +SEED_ADMIN_EMAIL= +SEED_ADMIN_PASSWORD= + +# --- Medusa --- +MEDUSA_JWT_SECRET= +MEDUSA_COOKIE_SECRET= +MEDUSA_AUTH_MFA_ENCRYPTION_KEY= +# Stripe: chiave segreta (sk_live_… / sk_test_…) e signing secret del webhook +# https://api.DOMAIN/hooks/payment/stripe_stripe (vedi CLAUDE.md → Stripe) +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= + +# --- Sito pubblico (apps/public) --- +# Chiave pubblicabile Stripe (pk_live_… / pk_test_…), stesso account di STRIPE_API_KEY +STRIPE_PUBLISHABLE_KEY= +# true solo sul dominio definitivo: altrimenti noindex ovunque e robots.txt "Disallow: /" +SITE_INDEXABLE=false + +# --- Backup (npm run backup:prod) --- +# Cartella dell'host dove scrivere i backup (da includere nel backup già presente sulla VPS) +BACKUP_DIR=./backups +# Numero di backup da tenere +BACKUP_KEEP_DAYS=14 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc684bc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# File eseguiti o letti dentro container Linux: sempre LF, anche con core.autocrlf su Windows +*.sh text eol=lf +Caddyfile text eol=lf +*.caddy text eol=lf +*.caddy.example text eol=lf +Dockerfile text eol=lf +*.sql text eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e7ceb3a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,31 @@ +name: Deploy stack su Proxmox + +on: + push: + branches: + - master + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Sul server DEPLOY_PATH è il clone del repo con il .env di produzione + # (da .env.prod.example). Il primo deploy (seed Medusa, admin) si fa a mano: + # vedi "Deploy (produzione)" in CLAUDE.md. + - name: Deploy via SSH (git pull + docker compose up -d --build) + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + port: ${{ secrets.DEPLOY_PORT }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + command_timeout: 45m + script: | + set -e + cd "${{ secrets.DEPLOY_PATH }}" + test -f .env || { echo "Manca .env in ${{ secrets.DEPLOY_PATH }} (copiare .env.prod.example)"; exit 1; } + git fetch origin master + git reset --hard origin/master + docker compose -f docker-compose.prod.yml build + docker compose -f docker-compose.prod.yml up -d --wait --wait-timeout 600 --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9304a6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Dipendenze +node_modules/ + +# Build +dist/ +.angular/ + +# Ambiente / segreti +.env +.env.* +!.env.example +!.env.prod.example + +# Dati runtime +uploads/ +logs/ +*.log + +# IDE / OS +.idea/ +.vscode/* +!.vscode/extensions.json +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Turbo / Medusa build +.turbo/ +.medusa/ + +# HTTP Client JetBrains: file con segreti +*.private.env.json + +# Email salvate in sviluppo (EMAIL_OUTBOX_DIR) +.outbox/ + +# Indice dei contenuti di apps/public (generato da scripts/content-index.mjs) +apps/public/src/content/generated/ +apps/public/public/vendor/ +apps/private/public/vendor/ + +# Backup di produzione (BACKUP_DIR di default) +backups/ + +# Dump dei crash di Git Bash su Windows +*.stackdump diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8a28324 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1436 @@ +# ITI TECHNOLOGY — Piattaforma web + +## Panoramica + +Portale web per ITI TECHNOLOGY: trattamento acqua, fotovoltaico/energia rinnovabile, comfort (pompe di calore, climatizzazione), mobilità elettrica, soluzioni industriali. Include e-commerce, blog/SEO, configuratore guidato, assistente AI (fase 2), area clienti, area installatori riservata, pannello admin. + +Due applicazioni separate, un solo backend custom, un motore e-commerce dedicato. + +## Architettura + +``` +iti-technology/ +├── apps/ +│ ├── public/ → Angular SSR — sito pubblico (home, soluzioni, blog, shop, configuratore, contatti) +│ ├── private/ → Angular SPA — area clienti / area installatori / admin +│ └── api/ → Fastify — backend custom, unico gateway verso Medusa +├── packages/ +│ ├── shared/ → @iti/shared — tipi e DTO condivisi (TypeScript) +│ └── styles/ → SCSS condiviso da public e private (token, mixin, base): solo file, nessun package.json +├── medusa/ → progetto standalone (create-medusa-app) — motore e-commerce; backend in medusa/apps/backend +├── prompts/ → prompt di implementazione (vedi "Stato implementazione") +├── infra/postgres/ → Dockerfile Postgres (PostGIS + pgvector) + script init +├── infra/caddy/ → Dockerfile (Caddy + build di apps/private), Caddyfile, sites/ per siti aggiuntivi +├── infra/backup/ → backup.sh / restore.sh (girano nei container "backup" e "restore") +├── docker-compose.dev.yml → infrastruttura locale (Postgres, Redis) +├── docker-compose.prod.yml → produzione (vedi "Deploy") +├── .env.prod.example → variabili di produzione (sulla VPS diventa `.env`) +├── tsconfig.base.json +├── SECURITY_REVIEW.md → review di sicurezza pre go-live (prompt 34): esito di ogni voce e punti aperti +└── CLAUDE.md +``` + +### Workspace npm + +- Root `package.json` (`iti-technology`, private) con `workspaces: ["apps/*", "packages/*"]`. **`medusa/` è escluso**: ha il suo `package.json` e `node_modules`, si installa a parte. +- Node 24 LTS (`.nvmrc`, `engines.node >= 24`), npm 11. TypeScript **`~6.0`** come devDependency di root (condiviso dai workspace). Portato da 5.9 a 6.0 nel prompt 22 perché Angular 22 richiede `>=6.0 <6.1`: `apps/api` e `packages/shared` compilano senza modifiche (typecheck, build e 207 test verificati). +- `tsconfig.base.json`: strict + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`; target ES2022, `module`/`moduleResolution` `NodeNext`. Ogni package la estende (le app Angular potranno sovrascrivere `module`/`moduleResolution` con `bundler`). +- Script root: `npm run build` (tutti i workspace con script `build`), `npm run build:shared`. +- Finché un'app non ha un `package.json`, la cartella `apps//` contiene solo `.gitkeep` e npm la ignora. + +### `packages/styles` (SCSS condiviso) + +- `_tokens.scss` (colori, font, spaziature, raggi, ombre, breakpoint), `_mixins.scss` (`mq.up`, `focus-ring`, `visually-hidden`), `_base.scss` (custom properties su `:root`, reset, tipografia, `.container`, `.btn`, form: `.form-grid`, `.form-field`, `fieldset`, `.check`, `.alert`), `_prose.scss` (mixin `prose.container` e `prose.content`: tipografia del corpo degli articoli del blog, usata dalla pagina articolo del sito **e** dall'anteprima dell'editor admin, così l'anteprima è identica al risultato; prompt 32). Spostati qui da `apps/public/src/styles` e `styles.scss` nel prompt 29. +- Le due app hanno `stylePreprocessorOptions.includePaths: ["../../packages/styles"]` in `angular.json`: nei componenti basta `@use 'tokens'` / `@use 'mixins' as mq`, e lo `styles.scss` di ogni app fa `@use 'base';` più le sue aggiunte. +- Non è un workspace npm (niente `package.json`): è una cartella letta da Sass. Una modifica qui vale per **entrambe** le app. + +### `packages/shared` (`@iti/shared`) + +- ESM (`"type": "module"`), compilato con `tsc` in `dist/` (JS + `.d.ts`), entry `exports["."]` → `dist/index.js`. Import relativi interni con estensione `.js` (richiesto da NodeNext). +- Va buildato prima di usarlo dalle app: lo fanno in automatico `postinstall` di root e `prebuild` di `apps/api`; dopo modifiche manuali `npm run build:shared`. +- Contiene `UserRole`/`USER_ROLES`, le costanti degli stati di dominio e i DTO pubblici (dettaglio nella sezione "Schema dati custom"). Qui vanno tipi, costanti e DTO condivisi tra `api`, `public` e `private`: niente logica runtime con dipendenze. +- Consumo dalle app: dipendenza `"@iti/shared": "*"` nel `package.json` dell'app (npm la risolve via symlink al workspace). + +### Infrastruttura di sviluppo (`docker-compose.dev.yml`) + +Progetto compose `iti-technology-dev`, variabili lette da `.env` in root (copiare `.env.example`; il compose fallisce se mancano). + +| Comando | Effetto | +|---|---| +| `npm run infra:up` | build immagine Postgres + avvio, attende i healthcheck (`--wait`) | +| `npm run infra:down` | ferma e rimuove i container (i volumi restano) | +| `npm run infra:logs` | log in follow | +| `docker compose -f docker-compose.dev.yml down -v` | reset completo: cancella i volumi, al prossimo `up` rigira l'init | + +- **postgres** (porta `POSTGRES_PORT`, default 5432): immagine custom `infra/postgres/Dockerfile` = `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (0.8.x). Volume `postgres-data`. + - L'immagine base è su Debian bullseye: il repo PGDG originale risponde 404, quindi il Dockerfile punta a `apt-archive.postgresql.org` (http, pacchetti firmati). Le immagini `postgis/postgis:16-*` sono tutte bullseye; valutare in futuro un'immagine bookworm. + - Init `infra/postgres/init/01-init.sql` (solo a volume vuoto): legge le credenziali con `\getenv` e crea i ruoli `APP_DB_USER` / `MEDUSA_DB_USER`, i database `APP_DB_NAME` (`iti_app`), `_test` (`iti_app_test`, per i test automatici di `apps/api`) e `MEDUSA_DB_NAME` (`iti_medusa`) ciascuno di proprietà del proprio utente, `CONNECT` revocato a `PUBLIC` (un utente non accede al DB dell'altro). In `iti_app` e `iti_app_test` crea le estensioni `postgis`, `vector`, `pg_trgm`, `unaccent`. + - Healthcheck `pg_isready -h 127.0.0.1`: diventa healthy solo a init completato. +- **redis** (`redis:7-alpine`, porta `REDIS_PORT`, default 6379, AOF attivo): volume `redis-data`, healthcheck `redis-cli ping`. URL in `REDIS_URL`. +- Variabili: `POSTGRES_USER/PASSWORD/DB/PORT`, `APP_DB_NAME/USER/PASSWORD`, `MEDUSA_DB_NAME/USER/PASSWORD`, `REDIS_PORT`, `REDIS_URL`. + +### Medusa (`medusa/`) + +Medusa **2.21.2**, creato con `create-medusa-app` (senza storefront, npm). Il template 2.21 è un piccolo monorepo **turbo**: root `medusa/package.json` (workspaces `apps/**`) e il backend vero e proprio in **`medusa/apps/backend`** (package `@dtc/backend`). Contiene il proprio `CLAUDE.md`/`AGENTS.md` del template con le convenzioni Medusa: leggerli quando si lavora lì dentro. + +**Avvio (dev)** — con l'infrastruttura su (`npm run infra:up` in root): +```powershell +cd medusa +npm install # solo la prima volta (progetto separato dai workspace di root) +npm run dev # turbo dev → medusa develop su http://localhost:9000 +``` +- Dashboard admin: **http://localhost:9000/app** · health: `GET http://localhost:9000/health` → 200 `OK`. +- Comandi CLI da lanciare **in `medusa/apps/backend`**: `npx medusa db:migrate` (migrazioni + indici search), `npx medusa user -e -p ` (crea un utente admin del dashboard). +- Utente admin di sviluppo: `admin@iti.local` (password comunicata in chat, non salvata nel repo; se persa ricreare un utente con il comando sopra). + +**Configurazione** (`medusa/apps/backend/medusa-config.ts`, env in `medusa/apps/backend/.env`, modello in `.env.example`): +- `DATABASE_URL` → database `iti_medusa` con utente `iti_medusa` (credenziali `MEDUSA_DB_*` del `.env` di root). +- `REDIS_URL` usato da `projectConfig.redisUrl` e dai moduli Redis consigliati dalla guida di deployment: `caching` (provider `caching-redis`), `event-bus-redis`, `workflow-engine-redis`, `locking` (provider `locking-redis`). Tutti sullo stesso Redis. +- **In produzione** (`NODE_ENV=production`) la config rifiuta di partire se `JWT_SECRET`, `COOKIE_SECRET`, `INTERNAL_API_SECRET` o `WEBHOOK_SECRET` mancano o hanno meno di 32 caratteri (senza `JWT_SECRET` Medusa userebbe `supersecret`). Avvisa se c'è `STRIPE_API_KEY` senza `STRIPE_WEBHOOK_SECRET`. +- CORS da env: `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS`. Il frontend non chiama Medusa (passa da `apps/api`), quindi `STORE_CORS` resta minimale. +- Segreti: `JWT_SECRET`, `COOKIE_SECRET`, `AUTH_MFA_ENCRYPTION_KEY` (generati casuali nel `.env` locale). +- File: modulo `@medusajs/medusa/file` con provider `file-local`; cartella `FILE_UPLOAD_DIR` (default `static`, relativa al backend) e URL pubblico `FILE_BACKEND_URL` (default `http://localhost:9000/static`). Medusa serve **sempre e solo** `/static` su `/static`: se in produzione `FILE_UPLOAD_DIR` punta a un volume diverso, quel volume va servito da Caddy e `FILE_BACKEND_URL` aggiornato. + +**Modifiche al template**: +- Rimossi `src/migration-scripts/initial-data-seed.ts` (veniva eseguito da `db:migrate` e creava regione "Europe", USD, spedizioni e prodotti demo) e `src/scripts/seed-demo-products.ts`; rimossi gli script `backend:seed` e `storefront:dev`. Il database `iti_medusa` è stato ricreato pulito. La configurazione di store/regione si fa nel prompt 04. +- Tenuto `src/search/` (indice prodotti del modulo search di Medusa, su Postgres); `PRICE_CURRENCIES` in `src/search/helpers/pricing.ts` ridotto a `["eur"]`. +- `medusa/package.json` impostato `private: true`; `overrides.lodash = ^4.18.1` (prompt 34: le versioni ≤ 4.17.23 hanno prototype pollution; Medusa verificata con 4.18.1); rimosso `.npmrc` (conteneva un'opzione solo pnpm che faceva emettere warning a npm). + +**Configurazione vendita Italia — `src/scripts/seed-base.ts`** (idempotente, rieseguibile: cerca ogni entità per nome/handle e crea solo ciò che manca): +```powershell +cd medusa/apps/backend +npx medusa exec ./src/scripts/seed-base.ts # stampa MEDUSA_PUBLISHABLE_KEY=pk_... +``` +Crea/allinea: +- store "ITI TECHNOLOGY": unica valuta **EUR, prezzi IVA inclusa** (`is_tax_inclusive`), default su sales channel Web / regione Italia / Magazzino ITI; +- regione **"Italia"** (paese `it`, EUR, `automatic_taxes`, IVA inclusa) con payment provider `pp_stripe_stripe`; +- tax region `it` (provider `tp_system`) con aliquota di default **IVA 22%** (codice `IVA22`, provvisoria); +- sales channel **"Web"** + publishable API key **"Web (apps/api)"** collegata → va in `apps/api/.env` come `MEDUSA_PUBLISHABLE_KEY`; +- stock location "Magazzino ITI" (provider fulfillment `manual_manual`), fulfillment set con service zone Italia, shipping option **"Spedizione standard"** flat **15 €** (placeholder, IVA inclusa); +- 3 prodotti di esempio pubblicati sul canale Web, una variante "Standard" ciascuno, giacenza 20: addolcitore 1.190 €, kit FV 6 kW 9.900 €, pompa di calore 8 kW 5.490 €. +- Medusa crea da sé al primo avvio anche "Default Sales Channel" e "Default Publishable API Key": non usati, lasciati lì. +- Importi Medusa v2 in **unità intere** (euro, non centesimi). +- Verificato via Store API: la regione espone `pp_stripe_stripe`; carrello con pompa di calore 5.490 € → `item_tax_total` 990 (IVA scorporata 22/122), spedizione 15 € con IVA, `total` 5.505. + +**Stripe**: +- Provider `@medusajs/medusa/payment-stripe`, id `stripe` → provider ID **`pp_stripe_stripe`**. Opzioni: `apiKey` = `STRIPE_API_KEY`, `webhookSecret` = `STRIPE_WEBHOOK_SECRET`, `capture: true` (cattura automatica all'autorizzazione). +- Il provider va in errore all'avvio senza `apiKey`, quindi `medusa-config.ts` lo registra **solo se `STRIPE_API_KEY` è impostata** (altrimenti warning); `seed-base.ts` si ferma se il provider manca. +- **Webhook da registrare nella dashboard Stripe** (Developers → Webhooks): **`https://api./hooks/payment/stripe_stripe`** (in produzione Caddy inoltra a Medusa solo questo percorso di `api.`), con gli eventi `payment_intent.amount_capturable_updated`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.partially_funded`. Il signing secret (`whsec_...`) va in `STRIPE_WEBHOOK_SECRET`. In locale si può usare `stripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe`. +- ⚠️ Nel `.env` locale `STRIPE_API_KEY` è un **segnaposto** (`sk_test_PLACEHOLDER_SOSTITUIRE`): il provider risulta nella regione ma creare una sessione di pagamento fallisce. Inserire la chiave di test reale prima del prompt 26 (checkout). + +**Listino installatori — `src/scripts/seed-installatori.ts`** (idempotente; richiede `seed-base.ts` già eseguito): +```powershell +cd medusa/apps/backend +npx medusa exec ./src/scripts/seed-installatori.ts # stampa id gruppo e price list +``` +- Customer group **`installatori`**, `metadata = { "code": "installer" }`. Id in dev: `cusgroup_01M3YXVSN2NHXNWAZ2CZQTCKS6`. L'id **cambia tra ambienti**: `apps/api` deve trovarlo per codice (gruppo con `metadata.code = "installer"`, oppure per nome `installatori`) o leggerlo da env, mai hardcodarlo. +- Price list **"Listino installatori"**: `type: override`, `status: active`, regola `customer.groups.id = [id gruppo]`. Prezzi EUR IVA inclusa: addolcitore 990, kit FV 8.400, pompa di calore 4.650. Lo script aggiunge solo i prezzi mancanti, non sovrascrive quelli modificati dal dashboard; riallinea stato e regola. +- Nota tecnica: il tipo TS di `createPriceListsWorkflow` non espone `type`, ma lo step lo inoltra al Pricing Module (come `POST /admin/price-lists`); senza, il default sarebbe `sale`. +- Verificato via Store API (stesso carrello con la pompa di calore): guest → 5.490, cliente normale → 5.490, cliente nel gruppo → **4.650**. + +**Come aggiungere/rimuovere un customer dal gruppo** (servirà ad `apps/api` all'approvazione di un installatore, prompt 20): +- Admin API: `POST /admin/customer-groups/{groupId}/customers` con body `{ "add": ["cus_..."] }` (o `{ "remove": [...] }`), autenticata come utente admin Medusa (`Authorization: Bearer ` da `POST /auth/user/emailpass`, oppure secret API key). +- Dentro Medusa (workflow/route custom, prompt 06): `linkCustomersToCustomerGroupWorkflow(container).run({ input: { id: groupId, add: [customerId] } })`. +- Il prezzo del gruppo si applica al carrello del customer. Un carrello già associato al customer **prima** dell'approvazione viene riprezzato solo alla successiva modifica delle righe (o a un nuovo `transferCartCustomer`); quelli associati dopo (login, `POST /internal/carts/:id/customer`) sono riprezzati subito. + +**Route interne `/internal/*`** (chiamabili solo da `apps/api`): +- Protezione: middleware `src/utils/internal-secret.ts` registrato in `src/api/middlewares.ts` su `/internal*`. Header **`x-internal-secret`** = **`INTERNAL_API_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Confronto con `timingSafeEqual` sui digest SHA-256 (nessuna fuga di contenuto o lunghezza). Segreto mancante o errato, oppure env non impostata → **401** `{"type":"unauthorized"}`. +- Body validati con zod (`src/api/internal/validators.ts`, `validateAndTransformBody`) → 400 `invalid_data`. Le email vengono normalizzate con **trim + minuscolo**: `apps/api` deve usare la stessa normalizzazione. +- Errori Medusa standard: 404 `not_found` per id inesistenti. + +| Route | Body / query | Implementazione | Risposta | +|---|---|---|---| +| `POST /internal/customers` | `{ email, first_name, last_name, company_name? }` | workflow custom `upsertAccountCustomerWorkflow` → cerca per email con `has_account=true`, altrimenti **`createCustomersWorkflow`** (con `has_account: true`) | `201 { customer, created: true }` / `200 { customer, created: false }` | +| `POST /internal/customers/:id/groups` | `{ action: "add" \| "remove" }` | workflow custom `setInstallerGroupWorkflow` → trova il gruppo per `metadata.code = "installer"`, poi **`linkCustomersToCustomerGroupWorkflow`** | `{ customer_id, group: { id, name }, member }` | +| `POST /internal/carts/:id/customer` | `{ customer_id }` | **`transferCartCustomerWorkflow`** (aggiorna customer ed email del carrello e **riprezza** le righe → listino installatori) | `{ cart }` (id, email, customer_id, total, items) | +| `POST /internal/carts/:id/guest` | `{ email }` | workflow custom `setCartGuestEmailWorkflow` → customer **guest** (`has_account=false`) per l'email (trovato o creato) + **`transferCartCustomerWorkflow`**; `not_allowed` se il carrello è già di un account | `{ cart_id, email }` | +| `POST /internal/prices` | `{ customer_id, region_id, variant_ids[] }` (max 200) | `query.graph` delle varianti con `calculated_price` nel contesto `{ region_id, currency_code, customer.groups }` del customer (come la Store API con customer autenticato) | `{ prices: { [variant_id]: { amount, original_amount, currency_code, price_list_type } } }` | +| `POST /internal/orders/claim` | `{ customer_id, email }` | per ogni ordine con quella email (ILIKE letterale, case-insensitive) il cui customer è guest e non annullato: workflow custom `claimGuestOrderWorkflow` = **`requestOrderTransferWorkflow`** + lettura token + **`acceptOrderTransferWorkflow`** | `{ claimed: [order_id], failed: [{ order_id, message }] }` | +| `GET /internal/orders/:id` | — | **`getOrderDetailWorkflow`** (niente draft) con righe (anche `thumbnail`), totali, indirizzo di spedizione, metodi di spedizione, fulfillment con `labels` (tracking) | `{ order }` / 404 | +| `GET /internal/customers/:id/orders` | `?limit=20&offset=0` (max 100) `[&display_id=N]` | **`getOrdersListWorkflow`** (filtro `customer_id`, niente draft, più recenti prima; con `display_id` solo quell'ordine, se è del customer: usato da `GET /me/orders/:number`) | `{ orders, count, offset, limit }` con `payment_status`, `fulfillment_status`, totali, righe | + +Workflow custom in `src/workflows/internal/`. Note: +- **Email del checkout guest — perché `/internal/carts/:id/guest`**: con la Store API, impostare l'email su un carrello senza customer (`POST /store/carts/:id { email }`, o `email` alla creazione) passa da `findOrCreateCustomerStep`, che cerca per email **senza filtrare `has_account`**. Se esiste un account registrato con quell'email, il carrello viene associato a quell'account con i suoi prezzi di gruppo: chiunque digiti l'email di un installatore otterrebbe il listino riservato, e l'ordine finirebbe nello storico dell'account. Per questo `apps/api` **non passa mai `email` alla Store API** e per i guest usa `/internal/carts/:id/guest`. Un carrello che ha già un customer guest può cambiare email senza problemi: resta guest. +- **Listini `type: override`**: nel `calculated_price` Medusa riporta come `original_amount` il prezzo del listino stesso, non quello pubblico. Il prezzo pubblico è il `calculated_price` calcolato senza contesto customer. +- **Customer account vs guest**: Medusa ammette un customer per coppia `(email, has_account)`. Il checkout guest crea customer con `has_account=false`; quelli creati da Fastify hanno `has_account=true`. Questo è **necessario**: `requestOrderTransferWorkflow` rifiuta il trasferimento verso customer guest. +- **Claim**: il flusso ufficiale prevede che il token arrivi al cliente via email. Qui viene letto e accettato internamente, quindi **`apps/api` deve chiamare il claim solo dopo aver verificato il possesso dell'email** (es. email verificata alla registrazione). Il claim richiede che il customer abbia la stessa email del body. Rieseguirlo non ha effetti (nessun ordine guest residuo). +- **Test manuali**: `medusa/apps/backend/http/internal.http` (HTTP Client WebStorm, ambiente `dev`). Le variabili pubbliche stanno in `http-client.env.json`, i segreti in `http-client.private.env.json` (gitignored; modello in `http-client.private.env.example.json`). L'ordine guest di test si crea come draft order via Admin API e poi si converte, così non serve un pagamento. +- **Verificato** (stesso scenario via curl): 401 su tutte e 5 le route senza segreto o con segreto errato; customer 201 → 200 con stesso id; carrello guest 5.490 → dopo gruppo e associazione **4.650**; claim dell'ordine guest (email con maiuscole) → `claimed`, ripetuto → vuoto; elenco ordini con l'ordine collegato; customer inesistente 404; body invalido 400. + +**Webhook Medusa → Fastify** (`src/subscribers/fastify-order-webhooks.ts` + `src/utils/fastify-webhook.ts`): +- Eventi (nomi verificati in `OrderWorkflowEvents` di Medusa 2.21): + + | Evento | Emesso da | `data` originale | + |---|---|---| + | `order.placed` | `completeCartWorkflow` (checkout), conversione draft order | `{ id }` | + | `order.canceled` | `cancelOrderWorkflow` | `{ id }` | + | `order.fulfillment_created` | `createOrderFulfillmentWorkflow` | `{ order_id, fulfillment_id, no_notification }` | + +- Destinazione: `POST {FASTIFY_WEBHOOK_URL}/webhooks/medusa`, `Content-Type: application/json`. +- **Payload**: + ```json + { + "id": "2f95bee9-bac2-52d3-848a-52abba09eeb7", + "event": "order.placed", + "occurred_at": "2026-10-02T18:43:21.765Z", + "data": { + "id": "order_01...", + "order": { "id": "order_01...", "display_id": 2, "status": "pending", "email": "cliente@example.com", + "customer_id": "cus_01...", "currency_code": "eur", "total": 1190, "created_at": "..." } + } + } + ``` + `data` = dati originali dell'evento + `order` (snapshot letto al momento dell'evento; `null` se non trovato). `occurred_at` = istante in cui il subscriber elabora l'evento. +- **`id` evento**: UUID v5 **deterministico** da `nome evento + data originale`. Lo stesso evento ha sempre lo stesso id, anche se il subscriber viene rieseguito, ed è identico in tutti i retry. `apps/api` deduplica su questo id (`webhook_events`, prompt 14). +- **Firma**: header **`x-signature`** = HMAC-SHA256 in **esadecimale minuscolo** del **body grezzo** (i byte esatti inviati) con chiave **`WEBHOOK_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Lato Fastify: calcolare l'HMAC sul raw body **prima** del parse JSON e confrontare con `timingSafeEqual`. Esempio di riferimento in `medusa/apps/backend/http/webhook-receiver.mjs`. +- **Retry**: fino a **5 tentativi** se la risposta non è 2xx o c'è un errore di rete (timeout 5 s per tentativo), con backoff esponenziale 2 s, 4 s, 8 s, 16 s (base configurabile con `WEBHOOK_RETRY_BASE_MS`). Ogni tentativo fallito è un `warn` nei log, l'abbandono è un `error` con il payload completo (per reinvio manuale). +- **Mai bloccante**: gli eventi arrivano dopo il commit dell'ordine; il subscriber prepara il payload e avvia la consegna **senza await**. Il worker dell'event bus elabora un job alla volta: con l'await i retry di un webhook bloccavano tutti gli altri subscriber, Medusa inclusa (verificato). +- **Limite noto**: i retry vivono in memoria. Se Medusa si riavvia durante il backoff, l'invio si perde (resta il log). Se servirà garanzia di consegna: outbox su DB oppure job di riconciliazione. +- Senza `FASTIFY_WEBHOOK_URL` o `WEBHOOK_SECRET` l'invio viene saltato con un warning. +- **Ricevitore**: `POST /webhooks/medusa` di `apps/api` (prompt 14, vedi sezione `apps/api`). **Receiver di test** standalone, utile per provare il subscriber senza l'API: `node http/webhook-receiver.mjs 3000` in `medusa/apps/backend`. Verifica la firma e stampa le richieste; con `FAIL=n` risponde 503 alle prime n. +- **Verificato**: ordine (draft convertito) → `order.placed` firmato; con `FAIL=2` → 503, 503, 200 al 3° tentativo (backoff 2 s, 4 s); `order.fulfillment_created` e `order.canceled` consegnati e firmati. Con l'endpoint giù l'ordine si completa in ~0,6 s e i 5 tentativi di `order.placed` e `order.fulfillment_created` compaiono nei log in parallelo, fino all'abbandono. +- ⚠️ In sviluppo, prima di riavviare Medusa assicurarsi che le istanze precedenti di `turbo dev`/`medusa develop` siano chiuse: chiudere solo il processo sulla porta 9000 lascia vivi i worker, che continuano a consumare gli eventi dalla coda Redis condivisa. + +**Decisione — prezzi IVA inclusa**: i prezzi in EUR per la regione Italia sono inseriti e mostrati **IVA inclusa** (vendita B2C); Medusa scorpora l'IVA nei totali. Vale anche per i listini (es. installatori, prompt 05), salvo diversa decisione. + +**Decisione — catalogo nell'MVP**: prodotti, categorie, prezzi, listini e ordini si gestiscono dal **dashboard admin integrato di Medusa** (`/app`). Il pannello admin custom (`apps/private`, prompt 32) non duplica la gestione catalogo (copre preventivi, ticket, installatori, utenti, blog, documenti, contatti/showroom) e mette un link diretto al dashboard Medusa. In produzione il dashboard è su `shop-admin.{DOMAIN}`, protetto da whitelist IP **e** basic auth (vedi "Deploy"). + +**Perché due app**: `public` ha bisogno di SSR per la SEO (blog, pagine soluzioni); `private` è dietro login, SPA è sufficiente, nessun bisogno di SSR. + +**Perché Medusa resta a parte**: ha il suo scaffolding e versioning proprio, si tratta come servizio esterno consumato via API, non si modifica la sua struttura interna. + +## Stack tecnico + +| Layer | Scelta | +|---|---| +| Frontend pubblico | Angular (standalone, signals, zoneless, SCSS) + SSR | +| Frontend privato | Angular (standalone, signals, zoneless, SCSS) SPA | +| Backend custom | Fastify / Node.js | +| Database app | PostgreSQL (+ pgvector per RAG futuro) | +| Geolocalizzazione | PostGIS (assegnazione installatori per zona) | +| E-commerce | Medusa (Node/TS), proprio Postgres separato | +| Pagamenti | Stripe (plugin ufficiale Medusa) | +| Storage media | Disco VPS, volume Docker + Caddy (no MinIO per ora) | +| Reverse proxy / TLS | Caddy, sottodomini sullo stesso dominio | +| Email transazionali | Provider esterno (da scegliere: Brevo o Resend) | +| Ricerca | `tsvector` Postgres (full-text nativo) | +| Hosting | VPS esistente, Docker Compose | +| Automazioni | n8n (istanza già esistente sulla VPS) | + +## Decisioni architetturali chiave + +**Autenticazione — login unico** +`apps/api` (Fastify) è l'unico gateway di autenticazione. Il frontend non parla mai direttamente con Medusa. +1. Login/registrazione → sempre verso Fastify, JWT proprio +2. Fastify crea prima la riga in `users` (Postgres), poi il customer Medusa corrispondente via Admin API, mappato con `users.medusa_customer_id` +3. Operazioni shop (carrello/checkout) → Fastify fa da proxy verso Medusa usando quella mappatura +4. Un solo login, un solo JWT, zero password duplicate + +**Sync Medusa → Fastify** +Subscriber scritto dentro il progetto Medusa (reagisce a `order.placed`, `order.completed`, ecc.) che chiama `/webhooks/medusa` su Fastify via HTTP. + +**Checkout** +Guest checkout abilitato (solo email, nessun account obbligatorio). Al momento della registrazione, Fastify cerca ordini Medusa passati con la stessa email e li collega al nuovo `user_id`. + +**Listino riservato installatori** +Customer groups + price lists in Medusa — nessuna logica di pricing custom da scrivere, è nativo. + +**IVA** +Aliquota flat 22% provvisoria per non bloccare il checkout. Prezzi inseriti e mostrati IVA inclusa (vedi sezione Medusa). Aliquote agevolate e fatturazione elettronica da affinare dopo il lancio. + +**Configuratore** +MVP: wizard a domande guidate con logica a regole (branching + tabelle di soglie in Fastify), **nessuna AI**. Output predicibile, zero costi/latenza OpenAI, genera comunque una `quote_request`. + +**Assistente AI — STEP 2, non nell'MVP** +Quando verrà implementato: RAG con pgvector. Schede tecniche → chunk → embedding OpenAI → salvati in Postgres. Domanda utente → embedding → similarity search → chunk rilevanti nel prompt → risposta GPT ancorata ai documenti reali (niente invenzioni). Serve sia per l'assistente conversazionale che per arricchire il configuratore in futuro. Attenzione GDPR: richiede DPA con OpenAI (dati utente verso processor extra-UE). + +**Editor blog** +Custom, markdown semplice: textarea + preview live nell'admin, salvato come testo in `blog_posts.content`, convertito in HTML a runtime (`marked`/`remark`) lato Fastify. Niente rich-text/WYSIWYG, niente dipendenze SaaS terze. + +**Monitoring** +Per ora log su file/console. Nessun Sentry o simile per il momento. + +## Schema dati custom (Postgres — apps/api) + +Database `iti_app`, schema Drizzle in `apps/api/src/db/schema/` (`users.ts` identità, `domain.ts` dominio, `columns.ts` tipi custom). Prodotti, ordini e carrelli **non sono in questo schema**: vivono nel Postgres di Medusa e si collegano tramite `users.medusa_customer_id`. + +**ERD sintetico** (PK = chiave primaria, FK → tabella [azione on delete]; `?` = nullable; tutte le tabelle hanno `created_at`, quasi tutte `updated_at`): +``` +users id PK · email (minuscola, unique lower()) · password_hash · role user_role + · first_name · last_name · company_name? · vat_number? · medusa_customer_id? unique + · email_verified_at? · disabled_at? + ├─< refresh_tokens user_id → users [cascade] · token_hash unique · family_id · expires_at · revoked_at? · user_agent? · ip? inet + ├─< password_reset_tokens user_id → users [cascade] · token_hash unique · expires_at · used_at? + ├─< email_verification_tokens (stessa struttura di password_reset_tokens) + ├─1 installer_profiles user_id PK → users [cascade] · business_name · vat_number? · phone? · address? + │ · location? geography(Point,4326) [GIST] · service_radius_km (>0, default 50) + │ · certifications text[] · status installer_status · approved_at? · notes? (solo admin) + ├─< quote_requests id · user_id? → users [set null] (anonime ammesse) · email (minuscola) · name · phone? · province? + │ │ · configurator_input jsonb · configurator_output? jsonb · status quote_request_status + │ └─< quotes id · quote_request_id? → quote_requests [set null] · user_id → users [restrict] + │ · year · seq (unique year+seq) · number unique "2026-0001" · title · status quote_status + │ · amount_cents bigint (≥0) · pdf_document_id? → documents [set null] · valid_until? date · sent_at? + ├─< tickets id · user_id → users [cascade] · subject · status ticket_status · priority ticket_priority + │ └─< ticket_messages id · ticket_id → tickets [cascade] · author_id → users [restrict] · body + ├─< blog_posts (author) author_id? → users [set null] + ├─< contact_requests id · user_id? → users [set null] · name · email · phone? · subject? · message · status contact_request_status + ├─< showroom_bookings id · user_id? → users [set null] · name · email · phone? · requested_at (inizio slot) · people (>0) · notes? · status showroom_booking_status + └─< consents id · user_id? → users [cascade] · email? (almeno uno dei due) · type consent_type · version · source? + · accepted_at · revoked_at? · ip? inet +blog_categories id · slug unique · name · description? + └─< blog_posts id · slug unique · title · excerpt? · content_md · cover_path? · status blog_post_status · published_at? + · seo_title? · seo_description? · category_id? → blog_categories [set null] + · search_vector tsvector GENERATED [GIN] +documents id · title · category? · file_path (relativo all'area private/) · mime_type · size_bytes bigint + · visibility document_visibility · product_ref? (handle Medusa) +webhook_events id uuid PK (= id evento Medusa, idempotenza) · event · payload jsonb · received_at · processed_at? · error? +``` + +**Enum Postgres** (valori da `@iti/shared`, unica fonte): `user_role` (customer, company, installer, admin) · `installer_status` (pending, approved, rejected) · `quote_request_status` (new, contacted, converted, closed) · `quote_status` (draft, sent, accepted, rejected, expired) · `ticket_status` (open, in_progress, waiting_customer, closed) · `ticket_priority` (low, normal, high, urgent) · `blog_post_status` (draft, published) · `document_visibility` (public, customer, installer, admin) · `contact_request_status` (new, in_progress, closed) · `showroom_booking_status` (requested, confirmed, cancelled, completed) · `consent_type` (privacy, marketing). + +**Note di schema**: +- **Email minuscole** ovunque (check `*_email_lowercase`): il codice deve normalizzare con trim + `toLowerCase()` prima di scrivere e cercare. +- **Full-text blog**: `search_vector` = `setweight(to_tsvector('italian', immutable_unaccent(title)),'A') || …(excerpt,'B') || …(content_md,'C')`, colonna generata `STORED` con indice GIN. Query: `search_vector @@ websearch_to_tsquery('italian', unaccent($q))`, ordinamento con `ts_rank`. `immutable_unaccent(text)` è un wrapper IMMUTABLE di `unaccent` (le colonne generate non accettano funzioni STABLE), creato dalla migrazione custom `0001_immutable_unaccent.sql`. +- **Geografia**: custom type `geographyPoint` (`columns.ts`). In TypeScript è `{ lat, lng }` (`GeoPoint` di shared): in scrittura diventa `ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography`, in lettura viene convertito da EWKB. Per i calcoli usare la colonna in `sql\`…\``: `ST_DWithin(location, punto, service_radius_km * 1000)` e `ST_Distance` (metri). +- **Numero preventivo**: `year` + `seq` (unique) e `number` = `${year}-${seq a 4 cifre}`; la generazione (prompt 19) prende `max(seq)+1` dell'anno e riprova sul conflitto unique. +- Migrazione `0003_quotes_sent_at`: `quotes.sent_at` (primo invio al cliente). +- ⚠️ **drizzle-kit e `geography`**: drizzle-kit genera `"geography(Point,4326)"` tra virgolette (tipo inesistente). In `0002_domain.sql` è stato corretto a mano; se una futura migrazione tocca la colonna `location`, correggere di nuovo il SQL generato prima di applicarlo. +- **Tipi riga** esportati da `apps/api/src/db/schema` (`User`, `InstallerProfile`, `Quote`, `BlogPost`, `NewBlogPost`, …: `$inferSelect` / `$inferInsert`). + +**`packages/shared`** (`@iti/shared`): +- `domain.ts`: costanti degli stati (`INSTALLER_STATUSES`, `QUOTE_STATUSES`, …) e relativi tipi; `user-role.ts`: `USER_ROLES` / `UserRole`. +- `dto.ts`: **DTO pubblici** delle risposte API (`UserDto`, `InstallerProfileDto`, `QuoteRequestDto`, `QuoteDto`, `TicketDto`, `TicketMessageDto`, `BlogCategoryDto`, `BlogPostSummaryDto`, `BlogPostDetailDto`, `BlogPostAdminDto`, `DocumentDto`, `ShowroomBookingDto`, `ShowroomBookingAdminDto`, `ShowroomSlotsDto`, `ContactRequestDto`, `FormTokenDto`, `ConsentDto`, `UserAdminDto`, `AdminSummaryDto`, `GeoPoint`, `Paginated`). Campi camelCase, date ISO string, importi in centesimi; mai campi interni (hash, `file_path`, `notes` admin, payload). Le route dei prompt successivi possono rifinirli. +- Ora contiene anche codice runtime (le costanti), quindi `apps/api` lo importa da `dist/`: viene compilato da `postinstall` di root (dopo ogni `npm install`) e da `prebuild` di `apps/api`. **Dopo una modifica a `packages/shared` lanciare `npm run build:shared`** (oppure `npx tsc -p packages/shared -w` mentre si sviluppa). + +## `apps/public` (Angular SSR) + +Package `@iti/public`: **Angular 22.2** (ultima stabile al 2026-10), standalone, signals, **zoneless** (default da Angular 21), SCSS, `@angular/ssr` con server Express. Generato con `ng new` (`--ssr --zoneless --style scss`), poi adattato al monorepo. + +```powershell +npm run content -w apps/public # rigenera l'indice dei contenuti (parte da solo prima di dev/build/test/typecheck) +npm run dev -w apps/public # ng serve → http://localhost:4200 (SSR anche in sviluppo, usa src/server.ts) +npm run build -w apps/public # build browser + server in apps/public/dist/public +npm run start -w apps/public # node dist/public/server/server.mjs → http://localhost:4000 +npm run test -w apps/public # ng test (vitest + jsdom) +npm run typecheck -w apps/public +``` + +**Struttura**: +``` +apps/public/ +├── public/ → statici: logo.svg, favicon.svg/.ico, og-default.png, images/aree/.svg (tutti segnaposto) +├── scripts/content-index.mjs → genera src/content/generated/ (indice dei file di contenuto) +├── scripts/vendor-assets.mjs → copia CSS e immagini di Leaflet in public/vendor/leaflet (generata, in .gitignore) +├── src/ +│ ├── content/ → CONTENUTI del sito (vedi "Contenuti" sotto) +│ ├── server.ts → Express: compressione, robots.txt, sitemap.xml, statici, poi rendering Angular +│ ├── server/ → runtime-config.ts (env del server), sitemap.ts (funzioni pure) +│ ├── environments/environment.ts → default di sviluppo (URL sito/API/area riservata, indexable=false) +│ ├── styles.scss → `@use 'base'` (token, reset, tipografia, .btn, form: tutto in packages/styles) +│ └── app/ +│ ├── app.ts / app.routes.ts / app.routes.server.ts / app.config(.server).ts +│ ├── core/ → site.ts (menu, slug validi, sitemap), app-config.ts, api.service.ts, auth.interceptor.ts, session.service.ts, cart.store.ts, seo.service.ts, structured-data.ts, format.ts, listing.ts (URL, ricerca e paginazione degli elenchi), blog.ts, shop.ts, configurator.ts (logica pura del wizard), consent.ts + consent.service.ts (cookie), form-guard.ts (anti-spam), contact.ts, showroom.ts, response-status.ts +│ ├── layout/ → header.ts, footer.ts, cookie-banner.ts +│ ├── shared/ → page-header, page-placeholder, not-found-view, solution-body, needs-picker, related-products, faq-list, quote-cta, latest-posts, post-card, product-card, list-search, pagination, filter-nav, placeholder-badge, consent-fields, honeypot, osm-map, legal-page +│ └── pages/ → un componente per pagina (export default, caricati lazy; la home ha home.html/home.scss) +``` + +**Convenzioni**: +- Componenti standalone con `ChangeDetectionStrategy.OnPush`, template e stili inline per i componenti piccoli, `input()`/`computed()`/`effect()`. I parametri di route arrivano come **input** (`withComponentInputBinding`). Le pagine sono `export default` e si caricano con `loadComponent: () => import('./pages/x')`. +- **TypeScript**: `tsconfig.json` estende `tsconfig.base.json` (strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`) con `module: preserve`, `moduleResolution: bundler`, lib DOM; `strictTemplates` ed extended diagnostics come errori. +- **Stili**: i token SCSS diventano custom properties su `:root` (`--color-primary`, `--space-4`, `--font-size-xl`…) e i componenti usano `var(--…)`. Breakpoint mobile-first con `@use 'mixins' as mq; @include mq.up(md) { … }` (`stylePreprocessorOptions.includePaths: ["../../packages/styles"]`, quindi basta `@use 'tokens'`/`'mixins'`; vedi "`packages/styles`"). Palette: blu acqua `#0b5c7a` (brand, uguale alle email), verde sostenibilità `#1f7a4d`, ambra energia `#f2a900`, un colore per macro-area (`--color-area-`). **Font di sistema**: nessuna richiesta a Google Fonts (GDPR, prestazioni). +- Locale `it-IT` registrato (`LOCALE_ID`), ``. + +**Contenuti** (`src/content/`): nell'MVP i testi di pagine istituzionali e soluzioni sono **file TypeScript versionati**, non un CMS. Ogni file esporta `default` con un helper tipizzato (`defineArea`, `defineSolution`, `defineCompanyPage` di `types.ts`), quindi un campo mancante o sbagliato è un errore di compilazione. ⚠️ **Tutti i testi attuali sono segnaposto** (commento in testa al file e `placeholder: true`): l'azienda fornirà quelli definitivi. In sviluppo le pagine con `placeholder: true` mostrano l'etichetta "Testo segnaposto" (in produzione no). + +| File | Contiene | URL | +|---|---|---| +| `areas/.ts` | macro-area: label del menu, `order`, titolo, tagline, description (meta), intro, immagine, vantaggi, "come funziona", `relatedProducts`, FAQ, `configuratorPath` | `/soluzioni/` | +| `solutions//.ts` | soluzione: stessi campi più `area` | `/soluzioni//` | +| `company/.ts` | pagina azienda: titolo, description, lead, sezioni (paragrafi ed elenchi), CTA facoltativa | `/azienda/` (`chi-siamo` è `/azienda`) | +| `home.ts` | testi della home sezione per sezione, partner e recensioni (**esempi**: pubblicare solo marchi autorizzati e recensioni reali; niente JSON-LD Review finché non lo sono) | `/` | +| `needs.ts` | esigenze di "Qual è la tua esigenza?" → percorso del configuratore | — | +| `projects.ts` | realizzazioni (area, località, anno, sintesi, immagine, `featured` per la home) | `/realizzazioni` | +| `showroom.ts` | testi e foto della pagina showroom (segnaposto, foto in `public/images/showroom/`) | `/showroom` | +| `legal.ts` | privacy policy, cookie policy e tabella dei cookie (`validated: false` → avviso "DA VALIDARE CON CONSULENTE PRIVACY") | `/privacy`, `/cookie` | +| `company.ts` | dati aziendali `COMPANY` ⚠️ **da completare** (ragione sociale, P.IVA, indirizzo, telefono, email, coordinate, social): i campi `null` vengono omessi da footer, home e JSON-LD, mai inventati. Orari allineati a `apps/api/src/showroom/config.ts` | — | + +- **Aggiungere una soluzione = creare un file** `src/content/solutions//.ts` (copiarne uno esistente). Il nome del file è lo slug (`a-z`, `0-9`, `-`), la cartella è l'area. Non serve toccare altro: route (`canMatch`), pagina dell'area, sitemap e footer la trovano da sole. Lo stesso vale per una nuova **pagina azienda** (`company/.ts`) o una nuova **area** (`areas/.ts` + almeno una soluzione; il colore `--color-area-` va aggiunto ai token e l'immagine in `public/images/aree/`). +- **Come funziona**: `scripts/content-index.mjs` scansiona le cartelle e scrive `src/content/generated/` (`paths.ts` con i soli slug per route e sitemap; `areas.ts`, `solutions.ts`, `company.ts` con gli import). Il builder Angular (esbuild) non supporta `import.meta.glob`, per questo serve il generatore. Parte da solo in `postinstall`, `predev`, `prebuild`, `pretest` e `pretypecheck`; la cartella è in `.gitignore`. **Con `npm run dev` già avviato**, dopo aver aggiunto un file lanciare `npm run content -w apps/public` (o riavviare il dev server). +- **Caricamento**: le aree (`content/areas.ts`) e le pagine azienda entrano nel bundle iniziale, perché servono a header e footer. Le soluzioni (`content/catalog.ts`) si caricano solo con le pagine (chunk lazy). Le route validano gli slug su `generated/paths.ts`, senza importare i contenuti. +- **Testi**: le `description` stanno tra 70 e 170 caratteri (lo verifica il test). Le risposte delle FAQ sono testo semplice, perché finiscono anche nel JSON-LD. Le immagini sono percorsi in `public/` con `alt` descrittivo e dimensioni intrinseche (per `NgOptimizedImage`). +- **Prodotti correlati**: `relatedProducts` contiene handle Medusa. La card legge `GET /shop/products/:handle` (prezzo IVA inclusa, link a `/shop/`). Un handle inesistente, o l'API spenta, fa sparire la card senza errori. Handle reali oggi: `addolcitore-domestico-20l`, `kit-fotovoltaico-6kw-accumulo`, `pompa-di-calore-aria-acqua-8kw`. +- **Validazione** (`src/content/content.spec.ts`): slug uguali ai nomi dei file, almeno una soluzione per area, campi obbligatori, lunghezza delle description, ordini univoci, FAQ → JSON-LD FAQPage valido, esigenze e realizzazioni su aree e percorsi esistenti, sitemap completa. + +**Parametri di query verso altre pagine** (da leggere nei prompt 27-28): +- `/configuratore?percorso=acqua|fotovoltaico|pompa_calore|azienda`: percorso preselezionato, inviato da "Qual è la tua esigenza?" e dalle CTA "Calcola una stima". Il wizard accetta anche `?esigenza=` come alias, con gli stessi valori: il prompt 27 citava `?esigenza=acqua-calcarea`, ma il sito usava già `percorso` con i valori dell'API. +- `/contatti?oggetto=preventivo|sopralluogo|installatori|candidatura[&soluzione=][&prodotto=]`: oggetto precompilato del form contatti ("Richiedi un preventivo", "Prenota un sopralluogo", pagine azienda; `prodotto` dalla scheda prodotto dello shop). + +**Route** (tutte lazy, `app.routes.ts`): + +| Percorso | Note | +|---|---| +| `/` | home in 11 sezioni (hero con "Trova la soluzione giusta" e "Richiedi preventivo", settori con Acqua in evidenza, energia rinnovabile, servizi per privati e aziende, "Qual è la tua esigenza?", realizzazioni, partner, recensioni, ultimi articoli dall'API, contatti); JSON-LD Organization + WebSite + LocalBusiness | +| `/azienda`, `/azienda/:pagina` | da `content/company/`: chi siamo, certificazioni, partner, lavora con noi, con sotto-menu; `/azienda/chi-siamo` → **301** `/azienda`; slug sconosciuto → 404; JSON-LD BreadcrumbList | +| `/soluzioni/:area`, `/soluzioni/:area/:soluzione` | da `content/areas` e `content/solutions`: descrizione, vantaggi, come funziona, (area) elenco soluzioni, prodotti correlati, FAQ, CTA preventivo, (soluzione) altre soluzioni dell'area. JSON-LD **BreadcrumbList + FAQPage**. Valori sconosciuti → 404 (`canMatch`); `/soluzioni` → **301** `/` | +| `/realizzazioni` | galleria da `content/projects.ts`, filtro `?area=` fatto di link (funziona anche lato server); canonical sempre `/realizzazioni` | +| `/shop`, `/shop/categoria/:categoria`, `/shop/:handle`, `/carrello` | catalogo, scheda prodotto e carrello (vedi "Shop e carrello" sotto); carrello `noindex` | +| `/checkout` | checkout in 4 passi con Stripe (vedi "Checkout" sotto), `noindex` | +| `/ordine/:numero/confermato` | conferma d'ordine, `noindex` (numero non numerico → 404) | +| `/blog`, `/blog/categoria/:categoria`, `/blog/:slug` | elenco con ricerca e paginazione, dettaglio articolo (vedi "Blog" sotto) | +| `/configuratore`, `/configuratore/grazie` | wizard a regole e ringraziamento (vedi "Configuratore" sotto); `grazie` è `noindex` | +| `/contatti`, `/showroom`, `/privacy`, `/cookie` | vedi "Contatti, showroom e cookie" sotto | +| `**` | 404 con **status HTTP 404** (`RESPONSE_INIT`) e `noindex` | + +- **404 decisi dalla pagina** (dati dall'API, es. articolo inesistente): `injectResponseStatus()` (`core/response-status.ts`) nel costruttore, poi `setStatus(404)` in un `effect` quando arriva la risposta. Funziona perché il server attende le richieste HTTP in corso prima di inviare la risposta. Contenuto con `` e `setPage({ …, noindex: true })`. Con l'API giù le pagine rispondono **503** (non 404, così l'URL non esce dall'indice). +- **Render mode** (`app.routes.server.ts`): **tutte `RenderMode.Server`**, niente prerender, perché la configurazione arriva dall'ambiente a runtime e il prerender la fisserebbe alla build. Status 301 sui redirect. + +**Configurazione runtime** (`core/app-config.ts`, `server/runtime-config.ts`): **la stessa build vale per qualunque dominio**. +- Il server legge `SITE_URL`, `PUBLIC_API_URL`, `API_INTERNAL_URL`, `PUBLIC_MEDIA_URL` (origine delle immagini pubbliche per la CSP, default `PUBLIC_API_URL`), `PRIVATE_APP_URL`, `SITE_INDEXABLE`, `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`/`pk_live_…`, validata all'avvio; vuota = checkout senza pagamento), `PORT` (default: `environment.ts`) e `NG_ALLOWED_HOSTS` (letta da Angular). Modello in `apps/public/.env.example`; in sviluppo non serve nessuna variabile. URL non validi → errore all'avvio. +- `SITE_CONFIG` (token): sul server viene dall'ambiente e finisce nel **TransferState**; il browser lo rilegge da lì (fallback `environment.ts`). `API_BASE_URL`: sul server è `API_INTERNAL_URL` (es. `http://api:3000` in Docker), nel browser `PUBLIC_API_URL`; se differiscono, `HTTP_TRANSFER_CACHE_ORIGIN_MAP` fa coincidere le chiavi della transfer cache. +- **`SITE_INDEXABLE=true` solo in produzione**: altrimenti tutte le pagine hanno `robots: noindex, nofollow` e `robots.txt` è `Disallow: /`. +- **Host ammessi** (protezione SSRF di Angular SSR): `angular.json` → `security.allowedHosts` contiene solo `localhost`/`127.0.0.1`; in produzione `NG_ALLOWED_HOSTS` = `DOMAIN`, impostata dal compose. + +**`ApiService`** (`core/api.service.ts`): `get/post/patch/delete(path, { query?, withCredentials? })` su `API_BASE_URL`; query senza valori vuoti; errori convertiti in **`ApiError { status, code, message, details }`** dal formato unico dell'API (rete/CORS → `NETWORK_ERROR`). `withCredentials: true` per le route con cookie (carrello). `provideHttpClient(withFetch())` + `provideClientHydration(withEventReplay(), withHttpTransferCacheOptions(...))`: **le GET fatte durante l'SSR non vengono ripetute** dal browser all'idratazione (le POST sì, mai in cache). + +**`SeoService`** (`core/seo.service.ts`): `setPage({ title, description, path?, image?, type?, noindex?, prev?, next? })` imposta title (` | ITI TECHNOLOGY`), description, robots, **canonical** (assoluto su `siteUrl`, assente se noindex), Open Graph (`og:title/description/url/image/type/site_name/locale`, immagine di default `/og-default.png`), `twitter:card` e, per gli elenchi paginati, `` (rimossi se assenti). `setJsonLd(id, data)` inserisce `", + '', + 'ok', + "[link](javascript:alert(3))", + 'esterno', + '', + '

stile

', + "![data](data:image/png;base64,AAAA)", + ].join("\n\n"), + ) + expect(html).not.toMatch(/') + expect(html).toContain('esterno') + }) + + it("il dettaglio pubblico e la preview restituiscono HTML ripulito", async () => { + const md = 'Ciao ' + const p = await createPost({ title: "XSS", contentMd: md, status: "published" }) + const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json() + expect(detail.contentHtml).not.toMatch(/ { + const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\n- uno\n- due") + expect(html).toContain("") + expect(html).toContain('') + expect(html).toContain("
  • uno
  • ") + }) +}) + +describe("admin", () => { + it("permessi: customer 403, anonimo 401 su tutte le route admin del blog", async () => { + const routes: ["GET" | "POST", string][] = [ + ["GET", "/admin/blog/posts"], + ["POST", "/admin/blog/posts"], + ["POST", "/admin/blog/preview"], + ["POST", "/admin/blog/categories"], + ] + for (const [method, url] of routes) { + const asCustomer = await t.app.inject({ method, url, headers: { authorization: `Bearer ${customer}` }, payload: {} }) + expect(asCustomer.statusCode, `${method} ${url}`).toBe(403) + expect((await t.app.inject({ method, url, payload: {} })).statusCode, `${method} ${url}`).toBe(401) + } + }) + + it("slug dal titolo, univoco, stabile al cambio titolo; slug esplicito duplicato → 409", async () => { + expect(slugify("Pompe di calore: guida 2026!")).toBe("pompe-di-calore-guida-2026") + expect(slugify("Perché l'acqua è dura?")).toBe("perche-lacqua-e-dura") + + const a = await createPost({ title: "Pompe di calore: guida" }) + const b = await createPost({ title: "Pompe di calore: guida" }) + const c = await createPost({ title: "Pompe di calore guida" }) + expect([a.slug, b.slug, c.slug]).toEqual(["pompe-di-calore-guida", "pompe-di-calore-guida-2", "pompe-di-calore-guida-3"]) + + const renamed = (await asAdmin("PATCH", `/admin/blog/posts/${a.id}`, { title: "Titolo nuovo" })).json() + expect(renamed.slug).toBe("pompe-di-calore-guida") + + const dup = await asAdmin("PATCH", `/admin/blog/posts/${b.id}`, { slug: "pompe-di-calore-guida" }) + expect(dup.statusCode).toBe(409) + expect(dup.json().error.code).toBe("SLUG_TAKEN") + expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", slug: "Non Valido!" })).statusCode).toBe(400) + }) + + it("stato: pubblicazione imposta published_at, PATCH parziale non tocca stato e contenuto", async () => { + const p = await createPost({ title: "Stato", contentMd: "Contenuto originale" }) + expect(p).toMatchObject({ status: "draft", publishedAt: null }) + + const pub = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "published" })).json() + expect(pub.status).toBe("published") + expect(Date.now() - Date.parse(pub.publishedAt)).toBeLessThan(10_000) + + // Regressione: un PATCH del solo titolo non deve rimettere in bozza né svuotare il contenuto + const titled = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { title: "Stato aggiornato" })).json() + expect(titled).toMatchObject({ status: "published", contentMd: "Contenuto originale", publishedAt: pub.publishedAt }) + + const back = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "draft" })).json() + expect(back.status).toBe("draft") + expect((await publicList()).total).toBe(0) + }) + + it("lista admin con bozze e filtro stato; DELETE; autore salvato", async () => { + await createPost({ title: "Bozza" }) + const live = await createPost({ title: "Live", status: "published" }) + expect((await asAdmin("GET", "/admin/blog/posts")).json().total).toBe(2) + expect((await asAdmin("GET", "/admin/blog/posts?status=draft")).json().items.map((p: { title: string }) => p.title)).toEqual(["Bozza"]) + + const [row] = await t.app.db.select().from(blogPosts).where(eq(blogPosts.id, live.id)) + expect(row!.authorId).not.toBeNull() + + expect((await asAdmin("DELETE", `/admin/blog/posts/${live.id}`)).statusCode).toBe(204) + expect((await asAdmin("GET", `/admin/blog/posts/${live.id}`)).statusCode).toBe(404) + }) + + it("categoria inesistente → 400; eliminare una categoria lascia i post senza categoria", async () => { + const bad = await asAdmin("POST", "/admin/blog/posts", { title: "X", categoryId: "00000000-0000-4000-8000-000000000000" }) + expect(bad.json().error.code).toBe("CATEGORY_NOT_FOUND") + + const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Acqua" })).json() + const p = await createPost({ title: "Con cat", categoryId: cat.id }) + expect((await asAdmin("DELETE", `/admin/blog/categories/${cat.id}`)).statusCode).toBe(204) + expect((await asAdmin("GET", `/admin/blog/posts/${p.id}`)).json().category).toBeNull() + }) + + it("copertina: solo percorsi dell'area immagini", async () => { + expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "../private/documents/x.pdf" })).statusCode).toBe(400) + expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "documents/2026/10/x.pdf" })).statusCode).toBe(400) + }) +}) diff --git a/apps/api/test/columns.test.ts b/apps/api/test/columns.test.ts new file mode 100644 index 0000000..1e11825 --- /dev/null +++ b/apps/api/test/columns.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest" +import { parseEwkbPoint } from "../src/db/schema/columns.js" + +describe("parseEwkbPoint", () => { + it("EWKB little-endian con SRID (formato restituito da Postgres)", () => { + expect(parseEwkbPoint("0101000020E6100000E17A14AE47612240CF66D5E76ABB4640")).toEqual({ lng: 9.19, lat: 45.4642 }) + }) + it("EWKB big-endian con SRID", () => { + expect(parseEwkbPoint("0020000001000010e6c05280624dd2f1aa40445b3d07c84b5e")).toEqual({ lng: -74.006, lat: 40.7128 }) + }) + it("rifiuta tipi diversi da Point", () => { + // LineString (tipo 2), little-endian, senza SRID + expect(() => parseEwkbPoint("010200000000000000")).toThrow(/Point/) + }) +}) diff --git a/apps/api/test/configurator.test.ts b/apps/api/test/configurator.test.ts new file mode 100644 index 0000000..09fcff3 --- /dev/null +++ b/apps/api/test/configurator.test.ts @@ -0,0 +1,240 @@ +import { eq } from "drizzle-orm" +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { definition } from "../src/configurator/definition.js" +import { evaluate, validateAnswers } from "../src/configurator/engine.js" +import { PROVINCES, RULES_VERSION } from "../src/configurator/rules.js" +import { consents, quoteRequests, users } from "../src/db/schema/index.js" +import { hashPassword } from "../src/services/password.js" +import { buildTestApp, type TestApp, truncateAll } from "./helpers.js" + +type Row = { + answers: Record + code: string + size: number | null + investment: number | null + savings: number | null + payback: number | null + products: string[] + warnings: number +} + +/** + * Casi tabellari (valori verificati a mano dalle formule di engine.ts con le regole di rules.ts). + * Cambiando una soglia in rules.ts questi valori vanno riallineati. + */ +const CASES: Record = { + acqua: [ + // 4 persone, 35 °f: carico 4×0,2×20 = 16 °f·m³/g, resina 16×4/5 = 12,8 L → 20 L; 1.190+300 → 1.500 €; risparmio 4×45×0,8 = 144 € + { answers: { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" }, code: "addolcitore", size: 20, investment: 1500_00, savings: 144_00, payback: 10.4, products: ["addolcitore-domestico-20l"], warnings: 0 }, + // 2 persone, durezza ignota (30 °f): resina 4,8 L → 10 L; risparmio 2×45×0,6 = 54 €; avvisi durezza + appartamento + { answers: { percorso: "acqua", acqua_persone: 2, acqua_durezza_nota: "no", acqua_abitazione: "appartamento" }, code: "addolcitore", size: 10, investment: 1500_00, savings: 54_00, payback: 27.8, products: ["addolcitore-domestico-20l"], warnings: 2 }, + // 12 °f ≤ 15 °f: addolcitore non necessario + { answers: { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "si", acqua_durezza: 12, acqua_abitazione: "casa" }, code: "filtrazione", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 }, + // 8 persone, 60 °f: resina 57,6 L > 40 L → sopralluogo + { answers: { percorso: "acqua", acqua_persone: 8, acqua_durezza_nota: "si", acqua_durezza: 60, acqua_abitazione: "casa" }, code: "addolcitore_sopralluogo", size: 57.6, investment: null, savings: null, payback: null, products: [], warnings: 0 }, + ], + fotovoltaico: [ + // MI (1150), 4.000 kWh, sud: 3,48 → 3,5 kWp; prod. 4.025; autoc. 40% → 1.610×0,30 + 2.415×0,10 = 724,5 €; 3,5×1.700 → 6.000 € + { answers: { percorso: "fotovoltaico", provincia: "MI", fv_input: "kwh", fv_consumo_kwh: 4000, fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" }, code: "fotovoltaico", size: 3.5, investment: 6000_00, savings: 725_00, payback: 8.3, products: [], warnings: 0 }, + // NA (1450), bolletta 150 €/mese → 6.000 kWh: 4,5 kWp, batteria 10 kWh, autoc. 85%; 4,5×1.700 + 10×600 = 13.650 → 13.700 € + { answers: { percorso: "fotovoltaico", provincia: "NA", fv_input: "bolletta", fv_bolletta_mensile: 150, fv_orientamento: "sud", fv_accumulo: "si", fv_uso_diurno: "alto" }, code: "fotovoltaico_accumulo", size: 4.5, investment: 13700_00, savings: 1762_00, payback: 7.8, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 0 }, + // RM (1300×0,85), 9.000 kWh: 8,5 kWp limitati dal tetto (30 m² / 5,5 → 5 kWp); batteria 15 kWh; autoc. 60% + { answers: { percorso: "fotovoltaico", provincia: "RM", fv_input: "kwh", fv_consumo_kwh: 9000, fv_orientamento: "est_ovest", fv_superficie_tetto: 30, fv_accumulo: "si", fv_uso_diurno: "basso" }, code: "fotovoltaico_accumulo", size: 5, investment: 17500_00, savings: 1216_00, payback: 14.4, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 1 }, + // AO (1150×0,75 = 862,5), 500 kWh: 0,58 kWp → minimo 2 kWp; prod. 1.725, autoc. limitato al consumo (500): + // 500×0,30 + 1.225×0,10 = 272,5 €; 2×2.000 = 4.000 €; avviso orientamento + { answers: { percorso: "fotovoltaico", provincia: "AO", fv_input: "kwh", fv_consumo_kwh: 500, fv_orientamento: "altro", fv_accumulo: "no", fv_uso_diurno: "basso" }, code: "fotovoltaico", size: 2, investment: 4000_00, savings: 273_00, payback: 14.7, products: [], warnings: 1 }, + ], + pompa_calore: [ + // TO (×1,15), 100 m² ristrutturata + ACS: 6,9 + 1,5 = 8,4 kW → 10 kW; 12.350 kWh; metano 1.572 € vs PdC 1.235 € + { answers: { percorso: "pompa_calore", provincia: "TO", pdc_superficie: 100, pdc_isolamento: "ristrutturato", pdc_combustibile: "metano", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore", size: 10, investment: 9500_00, savings: 337_00, payback: 28.2, products: [], warnings: 0 }, + // FI (×1), 120 m² nuova, pavimento: 4,8 kW → 6 kW; 6.000 kWh; gasolio 1.164,7 € vs 450 € + { answers: { percorso: "pompa_calore", provincia: "FI", pdc_superficie: 120, pdc_isolamento: "nuovo", pdc_combustibile: "gasolio", pdc_terminali: "pavimento", pdc_acs: "no" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 715_00, payback: 10.5, products: [], warnings: 0 }, + // PA (×0,8), 90 m² ristrutturata + ACS: 4,32 + 1,5 = 5,82 kW → 6 kW; 8.480 kWh; elettrico 2.544 € vs fancoil 726,9 € + { answers: { percorso: "pompa_calore", provincia: "PA", pdc_superficie: 90, pdc_isolamento: "ristrutturato", pdc_combustibile: "elettrico", pdc_terminali: "fancoil", pdc_acs: "si" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 1817_00, payback: 4.1, products: [], warnings: 0 }, + // BZ, 250 m² non isolata, termosifoni: 27,4 kW → sopralluogo + avviso isolamento + { answers: { percorso: "pompa_calore", provincia: "BZ", pdc_superficie: 250, pdc_isolamento: "vecchio", pdc_combustibile: "gpl", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore_sopralluogo", size: 27.4, investment: null, savings: 1755_00, payback: null, products: [], warnings: 1 }, + ], + azienda: [ + // BA (1450), 200.000 kWh: 96,6 → 97 kWp; prod. 140.650, autoc. 70%; 97×1.150 → 111.600 € + { answers: { percorso: "azienda", provincia: "BA", az_settore: "industria", az_interessi: ["fotovoltaico", "mobilita"], az_consumo_kwh: 200000 }, code: "consulenza_aziendale", size: 97, investment: 111600_00, savings: 33756_00, payback: 3.3, products: [], warnings: 0 }, + // Senza fotovoltaico: solo consulenza, nessuna stima + { answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] }, code: "consulenza_aziendale", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 }, + // PA, 5.000 kWh: 2,3 kWp → minimo aziendale 6 kWp; autoconsumo limitato al consumo + { answers: { percorso: "azienda", provincia: "PA", az_settore: "ricettivo", az_interessi: ["fotovoltaico"], az_consumo_kwh: 5000 }, code: "consulenza_aziendale", size: 6, investment: 10200_00, savings: 1900_00, payback: 5.4, products: [], warnings: 0 }, + ], +} + +describe("calcolo (funzioni pure, casi tabellari)", () => { + for (const [path, rows] of Object.entries(CASES)) { + describe(path, () => { + it.each(rows.map((r, i) => [i + 1, r] as const))("caso %i", (_i, row) => { + const v = validateAnswers(definition, row.answers) + expect(v.ok, JSON.stringify(v)).toBe(true) + if (!v.ok) return + const r = evaluate(v.answers) + expect(r.path).toBe(path) + expect(r.solution.code).toBe(row.code) + expect(r.size?.value ?? null).toBe(row.size) + expect(r.estimates).toEqual({ investmentCents: row.investment, annualSavingsCents: row.savings, paybackYears: row.payback }) + expect(r.products.map((p) => p.handle)).toEqual(row.products) + expect(r.warnings).toHaveLength(row.warnings) + expect(r.rulesVersion).toBe(RULES_VERSION) + expect(r.disclaimer).toMatch(/indicativa/) + expect(r.incentivesNote.length).toBeGreaterThan(20) + }) + }) + } +}) + +describe("validazione risposte", () => { + const ok = (a: Record) => validateAnswers(definition, a) + const errorsOf = (a: Record) => { + const r = ok(a) + return r.ok ? [] : r.errors + } + + it("percorso mancante o non valido", () => { + expect(errorsOf({})).toEqual([{ path: "answers.percorso", message: "Risposta obbligatoria" }]) + expect(errorsOf({ percorso: "nucleare" })[0]?.message).toBe("Opzione non valida") + }) + + it("obbligatorie mancanti nel percorso scelto", () => { + const errs = errorsOf({ percorso: "fotovoltaico", provincia: "MI" }) + expect(errs.map((e) => e.path).sort()).toEqual(["answers.fv_accumulo", "answers.fv_input", "answers.fv_orientamento", "answers.fv_uso_diurno"]) + }) + + it("risposte incoerenti con il branching: domande di altri percorsi o condizioni non soddisfatte", () => { + const water = { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "no", acqua_abitazione: "casa" } + expect(errorsOf({ ...water, acqua_durezza: 30 })).toEqual([{ path: "answers.acqua_durezza", message: "Risposta non prevista per le scelte fatte" }]) + expect(errorsOf({ ...water, pdc_superficie: 100 })[0]?.path).toBe("answers.pdc_superficie") + expect(errorsOf({ ...water, provincia: "MI" })[0]?.path).toBe("answers.provincia") + expect( + errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua"], az_consumo_kwh: 10000 })[0]?.path, + ).toBe("answers.az_consumo_kwh") + expect(errorsOf({ ...water, inventata: 1 })).toEqual([{ path: "answers.inventata", message: "Domanda inesistente" }]) + }) + + it("tipi, range, opzioni, province", () => { + const pv = { percorso: "fotovoltaico", fv_input: "kwh", fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" } + expect(errorsOf({ ...pv, provincia: "XX", fv_consumo_kwh: 4000 })[0]?.message).toBe("Provincia non valida") + expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 100 })[0]?.message).toMatch(/tra 500 e 30000/) + expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: "4000" })[0]?.message).toBe("Numero atteso") + expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 4000.5 })[0]?.message).toMatch(/passi/) + expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: [] })[0]?.message).toMatch(/almeno 1/) + expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua", "acqua"] })[0]?.message).toBe("Opzioni ripetute") + expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: "acqua" })[0]?.message).toMatch(/Elenco/) + }) + + it("la definizione è coerente: id unici, condizioni su domande precedenti, province con zona", () => { + const ids = definition.steps.flatMap((s) => s.questions.map((q) => q.id)) + expect(new Set(ids).size).toBe(ids.length) + const seen = new Set() + for (const step of definition.steps) { + for (const c of step.visibleIf ?? []) expect(seen.has(c.question), `${step.id} → ${c.question}`).toBe(true) + for (const q of step.questions) { + for (const c of q.visibleIf ?? []) expect(seen.has(c.question), `${q.id} → ${c.question}`).toBe(true) + seen.add(q.id) + } + } + expect(PROVINCES).toHaveLength(107) + expect(new Set(PROVINCES.map((p) => p.code)).size).toBe(107) + }) +}) + +describe("route", () => { + let t: TestApp + const water = { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" } + const contact = { name: "Mario Rossi", email: " Mario@Example.com ", phone: "3331234567" } + + beforeAll(async () => { + t = await buildTestApp() + }) + afterAll(() => t.app.close()) + beforeEach(async () => { + await truncateAll() + t.email.clear() + await t.app.db + .insert(users) + .values({ email: "admin@iti.local", passwordHash: await hashPassword("x".repeat(12)), role: "admin", firstName: "A", lastName: "D" }) + }) + + it("GET /configurator/definition", async () => { + const res = await t.app.inject({ method: "GET", url: "/configurator/definition" }) + expect(res.statusCode).toBe(200) + expect(res.json().steps[0].questions[0].id).toBe("percorso") + expect(res.json().provinces).toHaveLength(107) + }) + + it("POST /configurator/evaluate calcola senza salvare; risposte incoerenti → 400 con dettagli", async () => { + const res = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: water } }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ path: "acqua", solution: { code: "addolcitore" }, estimates: { investmentCents: 150000 } }) + expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0) + + const bad = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: { ...water, pdc_superficie: 80 } } }) + expect(bad.statusCode).toBe(400) + expect(bad.json().error).toMatchObject({ + code: "VALIDATION_ERROR", + details: [{ path: "answers.pdc_superficie", message: "Risposta non prevista per le scelte fatte" }], + }) + }) + + it("submit senza consenso privacy → 400, niente salvato", async () => { + for (const acceptPrivacy of [undefined, false]) { + const res = await t.app.inject({ method: "POST", url: "/configurator/submit", payload: { answers: water, contact, acceptPrivacy } }) + expect(res.statusCode).toBe(400) + } + expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0) + expect(t.email.sent).toHaveLength(0) + }) + + it("submit salva input/output e consensi, email all'admin e conferma all'utente", async () => { + const res = await t.app.inject({ + method: "POST", + url: "/configurator/submit", + payload: { answers: water, contact, message: "Preferisco essere contattato la mattina", acceptPrivacy: true, acceptMarketing: true }, + }) + expect(res.statusCode).toBe(201) + const { id, result } = res.json() + expect(result.solution.code).toBe("addolcitore") + + const [row] = await t.app.db.select().from(quoteRequests).where(eq(quoteRequests.id, id)) + expect(row).toMatchObject({ email: "mario@example.com", name: "Mario Rossi", phone: "3331234567", province: null, status: "new", userId: null }) + expect(row!.configuratorInput).toMatchObject({ answers: water, message: "Preferisco essere contattato la mattina" }) + expect(row!.configuratorOutput).toEqual(result) + + const c = await t.app.db.select().from(consents) + expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"]) + expect(c[0]).toMatchObject({ email: "mario@example.com", source: "configurator" }) + + const admin = t.email.last("quote-request-admin") + expect(admin?.to).toBe("admin@iti.local") + expect(admin?.text).toContain("Addolcitore 20 litri") + expect(admin?.text).toContain(`/admin/richieste/${id}`) + expect(admin?.text).toContain("Preferisco essere contattato la mattina") + const user = t.email.last("quote-request-received") + expect(user?.to).toBe("mario@example.com") + expect(user?.text).toMatch(/Investimento indicativo: 1\.500\s€/) + }) + + it("submit da utente autenticato collega la richiesta; provincia dalle risposte", async () => { + await t.app.db + .insert(users) + .values({ email: "cliente@example.com", passwordHash: await hashPassword("Password-di-test-1"), role: "customer", firstName: "C", lastName: "L" }) + const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "cliente@example.com", password: "Password-di-test-1" } }) + const res = await t.app.inject({ + method: "POST", + url: "/configurator/submit", + headers: { authorization: `Bearer ${login.json().accessToken}` }, + payload: { + answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] }, + contact: { name: "Cliente", email: "cliente@example.com" }, + acceptPrivacy: true, + }, + }) + expect(res.statusCode).toBe(201) + const [row] = await t.app.db.select().from(quoteRequests) + expect(row!.province).toBe("VR") + expect(row!.userId).not.toBeNull() + }) +}) diff --git a/apps/api/test/contact.test.ts b/apps/api/test/contact.test.ts new file mode 100644 index 0000000..16c36df --- /dev/null +++ b/apps/api/test/contact.test.ts @@ -0,0 +1,216 @@ +import { eq } from "drizzle-orm" +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { consents, contactRequests, refreshTokens, users } from "../src/db/schema/index.js" +import { checkFormToken, createFormToken, FORM_TOKEN_TTL_MS } from "../src/services/form-guard.js" +import { buildTestApp, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js" + +let t: TestApp +let ipSeq = 0 +const SECRET = "c".repeat(32) +const oldToken = () => createFormToken(SECRET, new Date(Date.now() - 10_000)).token + +const contact = (payload: Record, headers: Record = {}) => + t.app.inject({ + method: "POST", + url: "/contact", + headers, + remoteAddress: `10.1.0.${++ipSeq % 250}`, + payload: { + name: "Anna Bianchi", + email: " Anna@Example.com ", + message: "Vorrei informazioni sugli addolcitori", + acceptPrivacy: true, + formToken: oldToken(), + ...payload, + }, + }) + +beforeAll(async () => { + t = await buildTestApp() +}) +afterAll(() => t.app.close()) +beforeEach(async () => { + await truncateAll() + t.email.clear() +}) + +describe("token anti-spam", () => { + it("firma, tempo minimo e scadenza", () => { + const now = new Date("2026-10-03T10:00:00Z") + const { token } = createFormToken(SECRET, now) + expect(checkFormToken(SECRET, token, new Date(now.getTime() + 1000))).toBe("too_fast") + expect(checkFormToken(SECRET, token, new Date(now.getTime() - 60_000))).toBe("too_fast") // dal futuro + expect(checkFormToken(SECRET, token, new Date(now.getTime() + 5000))).toBe("ok") + expect(checkFormToken(SECRET, token, new Date(now.getTime() + FORM_TOKEN_TTL_MS + 1))).toBe("expired") + expect(checkFormToken("x".repeat(32), token, new Date(now.getTime() + 5000))).toBe("invalid") + const [issued, sig] = token.split(".") + expect(checkFormToken(SECRET, `${Number(issued) - 10_000}.${sig}`, now)).toBe("invalid") // data alterata + expect(checkFormToken(SECRET, "abc", now)).toBe("invalid") + }) + + it("GET /forms/token restituisce un token valido, non in cache", async () => { + const res = await t.app.inject({ method: "GET", url: "/forms/token" }) + expect(res.statusCode).toBe(200) + expect(res.headers["cache-control"]).toBe("no-store") + expect(res.json().minSeconds).toBe(3) + expect(checkFormToken(SECRET, res.json().token, new Date(Date.now() + 5000))).toBe("ok") + }) +}) + +describe("form contatti", () => { + it("salva la richiesta (email normalizzata), i consensi e avvisa lo staff", async () => { + await createUserAndLogin(t.app, "admin@iti.local", "admin") + t.email.clear() + const res = await contact({ subject: "Addolcitore", phone: "0391234567", acceptMarketing: true }) + expect(res.statusCode, res.body).toBe(200) + + const [saved] = await t.app.db.select().from(contactRequests) + expect(saved).toMatchObject({ email: "anna@example.com", subject: "Addolcitore", status: "new", userId: null }) + const c = await t.app.db.select().from(consents) + expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"]) + expect(c.every((x) => x.source === "contact" && x.version === "privacy-v1" && x.email === "anna@example.com")).toBe(true) + const mail = t.email.last("staff-notification") + expect(mail?.to).toBe("admin@iti.local") + expect(mail?.text).toContain("Vorrei informazioni sugli addolcitori") + expect(mail?.text).toContain(`/admin/contatti/${saved!.id}`) + }) + + it("senza consenso privacy → 400, nulla salvato", async () => { + for (const acceptPrivacy of [false, undefined]) { + const res = await contact({ acceptPrivacy }) + expect(res.statusCode).toBe(400) + expect(res.json().error.code).toBe("VALIDATION_ERROR") + } + expect(await t.app.db.select().from(contactRequests)).toHaveLength(0) + expect(await t.app.db.select().from(consents)).toHaveLength(0) + }) + + it("honeypot compilato o invio troppo rapido → 200, nulla salvato né inviato", async () => { + const honeypot = await contact({ website: "http://spam.example" }) + expect(honeypot.statusCode).toBe(200) + const fast = await contact({ formToken: createFormToken(SECRET).token }) + expect(fast.statusCode).toBe(200) + expect(fast.json()).toEqual(honeypot.json()) + expect(await t.app.db.select().from(contactRequests)).toHaveLength(0) + expect(await t.app.db.select().from(consents)).toHaveLength(0) + expect(t.email.sent).toHaveLength(0) + + const expired = await contact({ formToken: createFormToken(SECRET, new Date(Date.now() - FORM_TOKEN_TTL_MS - 1000)).token }) + expect(expired.statusCode).toBe(400) + expect(expired.json().error.code).toBe("FORM_EXPIRED") + expect((await contact({ formToken: undefined })).statusCode).toBe(400) + }) + + it("rate limit per IP: 5 invii ogni 15 minuti", async () => { + const send = () => + t.app.inject({ + method: "POST", + url: "/contact", + remoteAddress: "10.9.9.9", + payload: { name: "Bot", email: "bot@example.com", message: "x".repeat(20), acceptPrivacy: true, formToken: oldToken() }, + }) + for (let i = 0; i < 5; i++) expect((await send()).statusCode).toBe(200) + expect((await send()).statusCode).toBe(429) + }) + + it("utente autenticato collegato; admin: elenco, ricerca e cambio stato", async () => { + const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers + const mario = await createUserAndLogin(t.app, "mario@example.com") + await contact({ email: "mario@example.com", subject: "Fotovoltaico" }, mario.headers) + await contact({}) + + const list = await t.app.inject({ method: "GET", url: "/admin/contact-requests?q=mario", headers: admin }) + expect(list.json()).toMatchObject({ total: 1, items: [{ userId: mario.user.id, subject: "Fotovoltaico" }] }) + const id = list.json().items[0].id + const patched = await t.app.inject({ method: "PATCH", url: `/admin/contact-requests/${id}`, headers: admin, payload: { status: "closed" } }) + expect(patched.json().status).toBe("closed") + expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests?status=new", headers: admin })).json().total).toBe(1) + expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests", headers: mario.headers })).statusCode).toBe(403) + }) +}) + +describe("consensi dell'utente", () => { + it("elenco con i consensi anonimi della stessa email solo se verificata; revoca e nuovo consenso marketing", async () => { + // consensi dati da anonimo prima della registrazione + await contact({ email: "mario@example.com", acceptMarketing: true }) + const mario = await createUserAndLogin(t.app, "mario@example.com") + await t.app.db.insert(consents).values({ userId: mario.user.id, type: "privacy", version: "privacy-v1", source: "register" }) + + const get = () => t.app.inject({ method: "GET", url: "/me/consents", headers: mario.headers }) + const before = (await get()).json() + expect(before.marketing).toBe(true) + expect(before.items).toHaveLength(3) + expect(before.items[0]).toEqual({ + id: expect.any(String), + type: expect.any(String), + version: "privacy-v1", + source: expect.any(String), + acceptedAt: expect.any(String), + revokedAt: null, + }) + + const revoked = await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers }) + expect(revoked.json().marketing).toBe(false) + const marketing = revoked.json().items.filter((c: { type: string }) => c.type === "marketing") + expect(marketing).toHaveLength(1) + expect(marketing[0].revokedAt).not.toBeNull() + expect((await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers })).statusCode).toBe(200) + + const granted = await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) + expect(granted.json().marketing).toBe(true) + await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) // idempotente + const rows = await t.app.db.select().from(consents).where(eq(consents.type, "marketing")) + expect(rows.filter((c) => !c.revokedAt)).toHaveLength(1) + expect(rows.find((c) => !c.revokedAt)).toMatchObject({ source: "account", userId: mario.user.id }) + + // email non verificata: i consensi anonimi della stessa email non sono suoi + await contact({ email: "luigi@example.com", acceptMarketing: true }) + const luigi = await createUserAndLogin(t.app, "luigi@example.com", "customer", { emailVerifiedAt: null }) + const luigiConsents = (await t.app.inject({ method: "GET", url: "/me/consents", headers: luigi.headers })).json() + expect(luigiConsents).toEqual({ marketing: false, items: [] }) + await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: luigi.headers }) + const anon = await t.app.db.select().from(consents).where(eq(consents.email, "luigi@example.com")) + expect(anon.every((c) => c.revokedAt === null)).toBe(true) + + expect((await t.app.inject({ method: "GET", url: "/me/consents" })).statusCode).toBe(401) + }) +}) + +describe("richiesta di cancellazione dell'account", () => { + it("disabilita l'account, chiude le sessioni, revoca il marketing e avvisa lo staff", async () => { + await createUserAndLogin(t.app, "admin@iti.local", "admin") + const mario = await createUserAndLogin(t.app, "mario@example.com") + await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) + t.email.clear() + + const del = (password: string) => + t.app.inject({ method: "POST", url: "/me/delete-account", headers: mario.headers, payload: { password, reason: "Non uso più il servizio" } }) + const wrong = await del("password-sbagliata") + expect(wrong.statusCode).toBe(401) + expect((await t.app.db.select().from(users).where(eq(users.id, mario.user.id)))[0]!.disabledAt).toBeNull() + + expect((await del(TEST_PASSWORD)).statusCode).toBe(202) + const [user] = await t.app.db.select().from(users).where(eq(users.id, mario.user.id)) + expect(user!.disabledAt).not.toBeNull() + const tokens = await t.app.db.select().from(refreshTokens).where(eq(refreshTokens.userId, mario.user.id)) + expect(tokens.every((r) => r.revokedAt !== null)).toBe(true) + const marketing = await t.app.db.select().from(consents).where(eq(consents.userId, mario.user.id)) + expect(marketing.filter((c) => c.type === "marketing").every((c) => c.revokedAt !== null)).toBe(true) + + const mail = t.email.last("staff-notification") + expect(mail?.to).toBe("admin@iti.local") + expect(mail?.text).toContain("mario@example.com") + expect(mail?.text).toContain("Non uso più il servizio") + + // account non più utilizzabile + expect((await t.app.inject({ method: "GET", url: "/me", headers: mario.headers })).statusCode).toBe(401) + const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "mario@example.com", password: TEST_PASSWORD } }) + expect(login.statusCode).toBe(401) + }) + + it("un admin non può cancellarsi da solo", async () => { + const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers + const res = await t.app.inject({ method: "POST", url: "/me/delete-account", headers: admin, payload: { password: TEST_PASSWORD } }) + expect(res.statusCode).toBe(409) + }) +}) diff --git a/apps/api/test/db-security.test.ts b/apps/api/test/db-security.test.ts new file mode 100644 index 0000000..b085911 --- /dev/null +++ b/apps/api/test/db-security.test.ts @@ -0,0 +1,44 @@ +import { getTableColumns } from "drizzle-orm" +import { describe, expect, it } from "vitest" +import { emailVerificationTokens, passwordResetTokens, refreshTokens, users } from "../src/db/schema/index.js" +import { hashPassword, verifyPassword } from "../src/services/password.js" +import { generateToken, hashToken } from "../src/services/tokens.js" + +describe("token opachi", () => { + it("256 bit casuali, hash SHA-256 diverso dal token", () => { + const { token, hash } = generateToken() + expect(Buffer.from(token, "base64url")).toHaveLength(32) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + expect(hash).not.toContain(token) + expect(hashToken(token)).toBe(hash) + expect(generateToken().token).not.toBe(token) + }) +}) + +describe("password", () => { + it("argon2id, verifica ok/ko, hash malformato -> false", async () => { + const h = await hashPassword("una password lunga") + expect(h.startsWith("$argon2id$")).toBe(true) + expect(await verifyPassword(h, "una password lunga")).toBe(true) + expect(await verifyPassword(h, "sbagliata")).toBe(false) + expect(await verifyPassword("non-un-hash", "x")).toBe(false) + }) +}) + +describe("schema: niente segreti in chiaro", () => { + it.each([ + ["refresh_tokens", refreshTokens], + ["password_reset_tokens", passwordResetTokens], + ["email_verification_tokens", emailVerificationTokens], + ])("%s ha solo token_hash", (_name, table) => { + const columns = Object.values(getTableColumns(table)).map((c) => c.name) + expect(columns).toContain("token_hash") + expect(columns.filter((c) => /token|secret/.test(c) && c !== "token_hash")).toEqual([]) + }) + + it("users ha password_hash e nessuna colonna password in chiaro", () => { + const columns = Object.values(getTableColumns(users)).map((c) => c.name) + expect(columns).toContain("password_hash") + expect(columns).not.toContain("password") + }) +}) diff --git a/apps/api/test/documents.test.ts b/apps/api/test/documents.test.ts new file mode 100644 index 0000000..19420dd --- /dev/null +++ b/apps/api/test/documents.test.ts @@ -0,0 +1,296 @@ +import { existsSync } from "node:fs" +import { readdir, rm } from "node:fs/promises" +import { join } from "node:path" +import { Readable } from "node:stream" +import { eq } from "drizzle-orm" +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { documents, installerProfiles, users } from "../src/db/schema/index.js" +import { hashPassword } from "../src/services/password.js" +import { checkFileKind, InvalidStorageKeyError, LocalDiskStorage, sniffKind } from "../src/services/storage.js" +import { buildTestApp, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js" + +const PASSWORD = "Password-di-test-1" +const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\ntrailer << >>\n%%EOF\n") +const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32, 1)]) +const ZIP = Buffer.concat([Buffer.from("PK\x03\x04", "latin1"), Buffer.alloc(30)]) +const DWG = Buffer.concat([Buffer.from("AC1032"), Buffer.alloc(30)]) +const DXF = Buffer.from(" 0\r\nSECTION\r\n 2\r\nHEADER\r\n") +const HTML = Buffer.from("") + +let t: TestApp +const tokens: Record = {} + +async function user(email: string, role: "customer" | "company" | "installer" | "admin", installerStatus?: "pending" | "approved") { + const [u] = await t.app.db + .insert(users) + .values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U" }) + .returning() + if (installerStatus) await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Srl", status: installerStatus }) + const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } }) + tokens[email] = res.json().accessToken + return u! +} + +const auth = (email?: string) => (email ? { authorization: `Bearer ${tokens[email]}` } : {}) + +function upload( + file: { filename: string; contentType: string; content: Buffer | string } | null, + fields: Record = { title: "Scheda tecnica", visibility: "installer" }, + as = "admin@iti.local", + url = "/admin/documents", +) { + const body = multipartBody(fields, file ? [file] : []) + return t.app.inject({ method: "POST", url, payload: body.payload, headers: { ...body.headers, ...auth(as) } }) +} + +async function filesOnDisk(): Promise { + const out: string[] = [] + const walk = async (dir: string) => { + if (!existsSync(dir)) return + for (const e of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, e.name) + if (e.isDirectory()) await walk(p) + else out.push(p) + } + } + await walk(TEST_UPLOAD_DIR) + return out +} + +beforeAll(async () => { + t = await buildTestApp() +}) +afterAll(async () => { + await t.app.close() + await rm(TEST_UPLOAD_DIR, { recursive: true, force: true }) +}) +beforeEach(async () => { + await truncateAll() + await rm(TEST_UPLOAD_DIR, { recursive: true, force: true }) + await user("admin@iti.local", "admin") + await user("cliente@example.com", "customer") + await user("inst@example.com", "installer", "approved") + await user("attesa@example.com", "installer", "pending") +}) + +describe("upload e download per ruolo", () => { + it("un installatore approvato scarica i documenti installer; customer 403, installatore in attesa 403, anonimo 401", async () => { + const res = await upload({ filename: "Scheda PDC 8kW.pdf", contentType: "application/pdf", content: PDF }) + expect(res.statusCode).toBe(201) + const doc = res.json() + expect(doc).toMatchObject({ title: "Scheda tecnica", visibility: "installer", mimeType: "application/pdf", sizeBytes: PDF.length }) + expect(JSON.stringify(doc)).not.toMatch(/file_?path|documents\//i) + + const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("inst@example.com") }) + expect(dl.statusCode).toBe(200) + expect(dl.rawPayload.equals(PDF)).toBe(true) + expect(dl.headers["content-type"]).toBe("application/pdf") + expect(dl.headers["content-disposition"]).toBe(`attachment; filename="Scheda tecnica.pdf"; filename*=UTF-8''Scheda%20tecnica.pdf`) + expect(dl.headers["cache-control"]).toBe("private, no-store") + + const forbidden = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("cliente@example.com") }) + expect(forbidden.statusCode).toBe(403) + expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("attesa@example.com") })).statusCode).toBe(403) + expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })).statusCode).toBe(401) + expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("admin@iti.local") })).statusCode).toBe(200) + }) + + it("documento pubblico scaricabile da anonimo; documento inesistente 404", async () => { + const doc = (await upload({ filename: "catalogo.pdf", contentType: "application/pdf", content: PDF }, { title: "Catalogo", visibility: "public" })).json() + const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` }) + expect(dl.statusCode).toBe(200) + expect(dl.headers["cache-control"]).toBe("public, max-age=300") + expect((await t.app.inject({ method: "GET", url: "/documents/00000000-0000-4000-8000-000000000000/download" })).statusCode).toBe(404) + }) + + it("GET /documents elenca solo i livelli visibili al ruolo", async () => { + for (const visibility of ["public", "customer", "installer", "admin"]) { + await upload({ filename: "x.pdf", contentType: "application/pdf", content: PDF }, { title: visibility, visibility, category: "schede" }) + } + const titles = async (email?: string) => + ((await t.app.inject({ method: "GET", url: "/documents", headers: auth(email) })).json().items as { title: string }[]) + .map((d) => d.title) + .sort() + expect(await titles()).toEqual(["public"]) + expect(await titles("cliente@example.com")).toEqual(["customer", "public"]) + expect(await titles("attesa@example.com")).toEqual(["customer", "public"]) + expect(await titles("inst@example.com")).toEqual(["customer", "installer", "public"]) + expect(await titles("admin@iti.local")).toEqual(["admin", "customer", "installer", "public"]) + }) + + it("tutti i tipi ammessi: immagini, PDF, ZIP, DWG, DXF (MIME canonico)", async () => { + const cases = [ + { filename: "foto.PNG", contentType: "image/png", content: PNG, mime: "image/png" }, + { filename: "progetto.zip", contentType: "application/x-zip-compressed", content: ZIP, mime: "application/zip" }, + { filename: "pianta.dwg", contentType: "application/octet-stream", content: DWG, mime: "image/vnd.dwg" }, + { filename: "pianta.dxf", contentType: "application/dxf", content: DXF, mime: "image/vnd.dxf" }, + ] + for (const c of cases) { + const res = await upload(c, { title: c.filename, visibility: "admin" }) + expect(res.statusCode, c.filename).toBe(201) + expect(res.json().mimeType).toBe(c.mime) + } + }) +}) + +describe("rifiuti", () => { + it("estensione o MIME non consentiti, contenuto non coerente → 415 e niente su disco", async () => { + const rejected = [ + { filename: "virus.exe", contentType: "application/octet-stream", content: Buffer.from("MZ...") }, + { filename: "logo.svg", contentType: "image/svg+xml", content: Buffer.from("") }, + { filename: "pagina.html", contentType: "text/html", content: HTML }, + { filename: "finto.pdf", contentType: "application/pdf", content: HTML }, + { filename: "finta.jpg", contentType: "image/jpeg", content: PNG }, + { filename: "doc.pdf", contentType: "text/html", content: PDF }, + { filename: "senza-estensione", contentType: "application/pdf", content: PDF }, + { filename: "vuoto.pdf", contentType: "application/pdf", content: Buffer.alloc(0) }, + ] + for (const file of rejected) { + const res = await upload(file) + expect(res.statusCode, file.filename).toBe(415) + expect(res.json().error.code).toBe("UNSUPPORTED_FILE_TYPE") + } + expect(await filesOnDisk()).toEqual([]) + expect(await t.app.db.select().from(documents)).toHaveLength(0) + }) + + it("file oltre UPLOAD_MAX_BYTES → 413 e file parziale cancellato", async () => { + const small = await buildTestApp({ UPLOAD_MAX_BYTES: "100" }) + try { + const login = await small.app.inject({ method: "POST", url: "/auth/login", payload: { email: "admin@iti.local", password: PASSWORD } }) + const body = multipartBody({ title: "Grande", visibility: "admin" }, [ + { filename: "grande.pdf", contentType: "application/pdf", content: Buffer.concat([PDF, Buffer.alloc(500)]) }, + ]) + const res = await small.app.inject({ + method: "POST", + url: "/admin/documents", + payload: body.payload, + headers: { ...body.headers, authorization: `Bearer ${login.json().accessToken}` }, + }) + expect(res.statusCode).toBe(413) + expect(res.json().error.code).toBe("FILE_TOO_LARGE") + } finally { + await small.app.close() + } + expect(await filesOnDisk()).toEqual([]) + }) + + it("nessun file, due file, metadati non validi → 400 senza file residui", async () => { + expect((await upload(null)).json().error.code).toBe("FILE_REQUIRED") + + const two = multipartBody({ title: "Due" }, [ + { filename: "a.pdf", contentType: "application/pdf", content: PDF }, + { filename: "b.pdf", contentType: "application/pdf", content: PDF }, + ]) + const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: two.payload, headers: { ...two.headers, ...auth("admin@iti.local") } }) + expect(res.statusCode).toBe(400) + + const bad = await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "", visibility: "segreto" }) + expect(bad.statusCode).toBe(400) + expect(bad.json().error.code).toBe("VALIDATION_ERROR") + expect(await filesOnDisk()).toEqual([]) + }) + + it("solo admin: customer 403, anonimo 401", async () => { + expect((await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, undefined, "cliente@example.com")).statusCode).toBe(403) + expect((await t.app.inject({ method: "GET", url: "/admin/documents" })).statusCode).toBe(401) + }) +}) + +describe("path traversal e accesso statico", () => { + it("il nome file del client non entra nel percorso: chiave generata dal server", async () => { + const res = await upload({ filename: "../../../../etc/passwd.pdf", contentType: "application/pdf", content: PDF }) + expect(res.statusCode).toBe(201) + const [row] = await t.app.db.select().from(documents).where(eq(documents.id, res.json().id)) + expect(row!.filePath).toMatch(/^documents\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.pdf$/) + const files = await filesOnDisk() + expect(files).toHaveLength(1) + expect(files[0]!.startsWith(join(TEST_UPLOAD_DIR, "private", "documents"))).toBe(true) + }) + + it("LocalDiskStorage rifiuta chiavi con .., assolute o con caratteri non ammessi", async () => { + const storage = new LocalDiskStorage(TEST_UPLOAD_DIR, "http://x") + for (const key of ["../fuori.pdf", "a/../../fuori.pdf", "/etc/passwd", "C:\\Windows\\x", "a//b.pdf", "a/./b.pdf", "A.PDF", "%2e%2e/x"]) { + await expect(storage.put("private", key, Readable.from([PDF])), key).rejects.toBeInstanceOf(InvalidStorageKeyError) + await expect(storage.get("private", key), key).rejects.toBeInstanceOf(InvalidStorageKeyError) + } + }) + + it("i file privati non sono raggiungibili via /uploads; le immagini pubbliche sì", async () => { + const doc = await upload({ filename: "riservato.pdf", contentType: "application/pdf", content: PDF }) + const [row] = await t.app.db.select().from(documents).where(eq(documents.id, doc.json().id)) + for (const url of [ + `/uploads/${row!.filePath}`, + `/uploads/../private/${row!.filePath}`, + `/uploads/%2e%2e/private/${row!.filePath}`, + `/uploads/..%2fprivate/${row!.filePath}`, + `/uploads/%2e%2e%2fprivate%2f${row!.filePath.replaceAll("/", "%2f")}`, + ]) { + const res = await t.app.inject({ method: "GET", url }) + expect(res.statusCode, url).not.toBe(200) + expect(res.rawPayload.includes(Buffer.from("%PDF"))).toBe(false) + } + + const img = await upload({ filename: "copertina.png", contentType: "image/png", content: PNG }, {}, "admin@iti.local", "/admin/uploads/image") + expect(img.statusCode).toBe(201) + const { url, path } = img.json() + expect(path).toMatch(/^images\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.png$/) + expect(url).toBe(`http://localhost:3000/uploads/${path}`) + const served = await t.app.inject({ method: "GET", url: `/uploads/${path}` }) + expect(served.statusCode).toBe(200) + expect(served.rawPayload.equals(PNG)).toBe(true) + }) + + it("upload immagine pubblica: solo immagini", async () => { + const res = await upload({ filename: "doc.pdf", contentType: "application/pdf", content: PDF }, {}, "admin@iti.local", "/admin/uploads/image") + expect(res.statusCode).toBe(415) + }) +}) + +describe("gestione admin", () => { + it("elenco con filtri, PATCH metadati, DELETE cancella anche il file", async () => { + const a = (await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "Manuale addolcitore", visibility: "customer", category: "manuali", productRef: "addolcitore-30l" })).json() + await upload({ filename: "b.pdf", contentType: "application/pdf", content: PDF }, { title: "Listino 2026", visibility: "installer" }) + + const list = await t.app.inject({ method: "GET", url: "/admin/documents?q=manuale", headers: auth("admin@iti.local") }) + expect(list.json()).toMatchObject({ total: 1, items: [{ title: "Manuale addolcitore", productRef: "addolcitore-30l", category: "manuali" }] }) + expect((await t.app.inject({ method: "GET", url: "/admin/documents?visibility=installer", headers: auth("admin@iti.local") })).json().total).toBe(1) + + const patched = await t.app.inject({ + method: "PATCH", + url: `/admin/documents/${a.id}`, + headers: auth("admin@iti.local"), + payload: { visibility: "public", category: null }, + }) + expect(patched.json()).toMatchObject({ visibility: "public", category: null, title: "Manuale addolcitore" }) + expect((await t.app.inject({ method: "GET", url: `/documents/${a.id}/download` })).statusCode).toBe(200) + + // Regressione: un PATCH senza visibility non deve riportarla al default "admin" + const onlyTitle = await t.app.inject({ + method: "PATCH", + url: `/admin/documents/${a.id}`, + headers: auth("admin@iti.local"), + payload: { title: "Manuale addolcitore v2" }, + }) + expect(onlyTitle.json()).toMatchObject({ title: "Manuale addolcitore v2", visibility: "public" }) + + expect(await filesOnDisk()).toHaveLength(2) + expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(204) + expect(await filesOnDisk()).toHaveLength(1) + expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(404) + }) +}) + +describe("riconoscimento tipo file", () => { + it("sniffKind e checkFileKind", () => { + expect(sniffKind(PDF)).toBe("pdf") + expect(sniffKind(PNG)).toBe("png") + expect(sniffKind(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg") + expect(sniffKind(Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe("webp") + expect(sniffKind(DWG)).toBe("dwg") + expect(sniffKind(DXF)).toBe("dxf") + expect(sniffKind(HTML)).toBeNull() + expect(checkFileKind("a.pdf", "application/pdf", PDF, ["pdf"])).toEqual({ ok: true, kind: "pdf", ext: ".pdf" }) + expect(checkFileKind("a.pdf", "application/pdf", PDF, ["png"]).ok).toBe(false) + }) +}) diff --git a/apps/api/test/emails.test.ts b/apps/api/test/emails.test.ts new file mode 100644 index 0000000..e48ffab --- /dev/null +++ b/apps/api/test/emails.test.ts @@ -0,0 +1,266 @@ +import { mkdtemp, readdir, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import pino from "pino" +import { describe, expect, it, vi } from "vitest" +import { loadConfig } from "../src/config/env.js" +import { EMAIL_TEMPLATES, type EmailTemplate, type EmailTemplates, type OrderEmailData, renderEmail } from "../src/emails/index.js" +import { + BrevoTransport, + ConsoleTransport, + EmailDeliveryError, + EmailSender, + type EmailTransport, + renderMessage, + ResendTransport, +} from "../src/services/email.js" +import { LogCapture, testConfig } from "./helpers.js" + +const XSS = `&"'` +const EVIL_URL = "javascript:alert(1)" + +const order: OrderEmailData = { + number: 12, + email: "cliente@example.com", + customerName: `Mario ${XSS}`, + guest: false, + items: [{ name: `Addolcitore ${XSS}`, quantity: 2, total: 238000 }], + itemTotal: 238000, + shippingTotal: 1500, + discountTotal: 0, + taxTotal: 43188, + total: 239500, + shippingAddress: ["Mario Rossi", `Via Roma 1 ${XSS}`, "20100 Milano (MI)"], +} + +/** Dati di esempio per ogni template, con contenuti ostili nei campi testuali. */ +const samples: { [K in EmailTemplate]: EmailTemplates[K] } = { + "verify-email": { firstName: `Mario ${XSS}`, link: "https://area.example.it/verify-email?token=abc" }, + "reset-password": { firstName: `Mario ${XSS}`, link: "https://area.example.it/reset-password?token=abc" }, + "register-existing": { firstName: `Mario ${XSS}`, forgotPasswordUrl: "https://area.example.it/forgot-password" }, + "order-confirmation": { order, ordersUrl: "https://area.example.it/ordini" }, + "order-placed-admin": { order }, + "order-canceled": { order }, + "order-shipped": { order, tracking: [{ number: `BRT${XSS}`, url: "https://track.example/BRT1" }] }, + "quote-request-admin": { + name: `Ugo ${XSS}`, + email: "ugo@example.com", + phone: "333 1234567", + province: "TO", + summary: [{ label: "Soluzione", value: `Fotovoltaico ${XSS}` }], + adminUrl: "https://area.example.it/admin/preventivi/1", + }, + "quote-request-received": { + name: `Ugo ${XSS}`, + solutionTitle: `Addolcitore ${XSS}`, + summary: [{ label: "Investimento", value: `1.500 € ${XSS}` }], + disclaimer: "Stima indicativa", + }, + "quote-sent": { + firstName: `Ugo ${XSS}`, + quoteNumber: "2026-0007", + title: `Impianto FV 6 kW ${XSS}`, + amountCents: 990000, + validUntil: "2026-11-30", + link: "https://area.example.it/preventivi/7", + }, + "ticket-reply": { + firstName: `Ugo ${XSS}`, + ticketSubject: `Perdita ${XSS}`, + message: `Riga 1\nRiga 2 ${XSS}`, + link: "https://area.example.it/ticket/3", + }, + "staff-notification": { + title: `Nuovo ticket ${XSS}`, + intro: `Ticket da ${XSS}`, + rows: [{ label: "Oggetto", value: `Perdita ${XSS}` }], + url: "https://area.example.it/admin/ticket/1", + }, + "installer-approved": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, link: "https://area.example.it/installatori" }, + "installer-rejected": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, reason: `Motivo ${XSS}` }, + "showroom-booking-confirmed": { + name: `Ugo ${XSS}`, + requestedAt: "2026-10-10T13:00:00.000Z", + people: 3, + notes: `Parcheggio ${XSS}`, + }, +} + +describe("template", () => { + it("c'è un esempio per ogni template registrato", () => { + expect(Object.keys(samples).sort()).toEqual([...EMAIL_TEMPLATES].sort()) + }) + + for (const template of EMAIL_TEMPLATES) { + it(`${template}: oggetto, HTML con layout, testo; dati escapati`, () => { + const r = renderEmail(template, samples[template] as never) + expect(r.subject.length).toBeGreaterThan(5) + expect(r.subject).not.toMatch(/[\r\n]/) + expect(r.html).toMatch(/^/) + expect(r.html).toContain("ITI TECHNOLOGY") + expect(r.text).toContain("ITI TECHNOLOGY") + // Nessun contenuto ostile non escapato nell'HTML + expect(r.html).not.toContain("

    Caricamento dell'area riservata…

    + } + + `, + styles: ` + .boot { + padding: var(--space-8) var(--gutter); + text-align: center; + color: var(--color-ink-muted); + } + `, +}) +export class App { + protected readonly auth = inject(AuthStore) + + constructor() { + void this.auth.ready() + } +} diff --git a/apps/private/src/app/core/api.service.ts b/apps/private/src/app/core/api.service.ts new file mode 100644 index 0000000..e7444fa --- /dev/null +++ b/apps/private/src/app/core/api.service.ts @@ -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 { APP_CONFIG } 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 + +export interface RequestOptions { + query?: Query + /** Invia i cookie (refresh token, carrello): serve solo alle route `/auth/*` e `/shop/*`. */ + withCredentials?: boolean + /** Non aggiungere l'access token e non tentare il refresh su 401 (login, registrazione, reset…). */ + anonymous?: boolean +} + +/** + * Richiesta da NON autenticare (vedi auth.interceptor.ts): le route pubbliche di `/auth/*` e le chiamate + * dell'AuthStore stesso (refresh, login, logout), che altrimenti finirebbero in un ciclo di refresh. + */ +export const SKIP_AUTH = new HttpContextToken(() => false) + +function httpOptions(options: RequestOptions) { + return { + params: toParams(options.query), + withCredentials: options.withCredentials ?? false, + context: new HttpContext().set(SKIP_AUTH, options.anonymous ?? 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 ApiError) return err + 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. Le richieste sono autenticate da authInterceptor (access token in memoria, + * refresh trasparente), salvo `anonymous: true`. Gli errori arrivano sempre come `ApiError`. + */ +@Injectable({ providedIn: 'root' }) +export class ApiService { + private readonly http = inject(HttpClient) + private readonly baseUrl = inject(APP_CONFIG).apiUrl + + url(path: string): string { + return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}` + } + + get(path: string, options: RequestOptions = {}): Observable { + return this.http.get(this.url(path), httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + post(path: string, body: unknown, options: RequestOptions = {}): Observable { + return this.http + .post(this.url(path), body, httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + put(path: string, body: unknown, options: RequestOptions = {}): Observable { + return this.http.put(this.url(path), body, httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + patch(path: string, body: unknown, options: RequestOptions = {}): Observable { + return this.http + .patch(this.url(path), body, httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + delete(path: string, options: RequestOptions = {}): Observable { + return this.http.delete(this.url(path), httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } +} diff --git a/apps/private/src/app/core/app-config.ts b/apps/private/src/app/core/app-config.ts new file mode 100644 index 0000000..31d5245 --- /dev/null +++ b/apps/private/src/app/core/app-config.ts @@ -0,0 +1,60 @@ +import { InjectionToken } from '@angular/core' +import { environment } from '../../environments/environment' + +/** Configurazione runtime dell'area riservata. */ +export interface AppConfig { + /** URL dell'API Fastify, senza slash finale. */ + apiUrl: string + /** URL del sito pubblico, senza slash finale. */ + siteUrl: string + /** Dashboard admin di Medusa (in produzione `https://shop-admin./app`), senza slash finale. */ + medusaAdminUrl: string +} + +export const DEFAULT_CONFIG: AppConfig = environment + +/** Configurazione dell'app: fornita da `main.ts` dopo aver letto `/config.json`; nei test valgono i default. */ +export const APP_CONFIG = new InjectionToken('APP_CONFIG', { + providedIn: 'root', + factory: () => DEFAULT_CONFIG, +}) + +/** URL http(s) assoluto senza slash finale, oppure null. */ +function normalizeUrl(value: unknown): string | null { + if (typeof value !== 'string' || value.trim() === '') return null + try { + const url = new URL(value.trim()) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.href.replace(/\/+$/, '') + } catch { + return null + } +} + +/** Fonde i valori letti da `config.json` con i default; i campi assenti o non validi restano ai default. */ +export function parseRuntimeConfig(raw: unknown, defaults: AppConfig = DEFAULT_CONFIG): AppConfig { + const data = (raw && typeof raw === 'object' ? raw : {}) as Record + const config = { ...defaults } + for (const key of ['apiUrl', 'siteUrl', 'medusaAdminUrl'] as const) { + const value = normalizeUrl(data[key]) + if (value) config[key] = value + else if (key in data) console.warn(`config.json: "${key}" non è un URL valido, uso ${defaults[key]}`) + } + return config +} + +/** + * Legge `/config.json` (generato in produzione dalle variabili d'ambiente, prompt 33) prima del bootstrap. + * Se manca (sviluppo) valgono i default di environment.ts. + */ +export async function loadRuntimeConfig(): Promise { + try { + const res = await fetch('config.json', { cache: 'no-store' }) + if (res.status === 404) return DEFAULT_CONFIG + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return parseRuntimeConfig(await res.json()) + } catch (err) { + console.warn('config.json non leggibile, uso la configurazione di default', err) + return DEFAULT_CONFIG + } +} diff --git a/apps/private/src/app/core/auth.guards.ts b/apps/private/src/app/core/auth.guards.ts new file mode 100644 index 0000000..91461ee --- /dev/null +++ b/apps/private/src/app/core/auth.guards.ts @@ -0,0 +1,61 @@ +import type { UserRole } from '@iti/shared' +import { DOCUMENT } from '@angular/common' +import { inject } from '@angular/core' +import { type CanActivateFn, type CanMatchFn, Router, type UrlTree } from '@angular/router' +import { APP_CONFIG } from './app-config' +import { AuthStore } from './auth.store' +import { resolvePostLogin } from './redirect' +import { homeFor } from './roles' + +function loginTree(router: Router, returnUrl: string | null | undefined): UrlTree { + const redirect = returnUrl && returnUrl !== '/' ? returnUrl : null + return router.createUrlTree(['/login'], { queryParams: { redirect } }) +} + +/** Route riservate agli utenti autenticati: senza sessione → `/login?redirect=`. */ +export const authGuard: CanActivateFn = async (_route, state) => { + const auth = inject(AuthStore) + const router = inject(Router) + await auth.ready() + return auth.isAuthenticated() ? true : loginTree(router, state.url) +} + +/** + * Sezione riservata a certi ruoli (`canMatch`: il codice lazy di una sezione non permessa non viene scaricato). + * Senza sessione → login; ruolo non ammesso → pagina iniziale del proprio ruolo. + * Il controllo vero resta nell'API (`requireRole`): questo evita solo di mostrare pagine inutilizzabili. + */ +export function roleGuard(...roles: UserRole[]): CanMatchFn { + return async () => { + const auth = inject(AuthStore) + const router = inject(Router) + await auth.ready() + const role = auth.role() + if (!role) { + const navigation = router.currentNavigation() + return loginTree(router, navigation ? router.serializeUrl(navigation.extractedUrl) : null) + } + return roles.includes(role) ? true : router.parseUrl(homeFor(role)) + } +} + +/** + * Pagine per utenti non autenticati (login, registrazione, password dimenticata): chi ha già una sessione + * va dove avrebbe portato il login (`?redirect=` validato) o alla pagina iniziale del ruolo. + */ +export const guestGuard: CanActivateFn = async (route) => { + // inject() solo prima dell'await: dopo, il contesto di injection non c'è più + const auth = inject(AuthStore) + const router = inject(Router) + const document = inject(DOCUMENT) + const siteUrl = inject(APP_CONFIG).siteUrl + await auth.ready() + const user = auth.user() + if (!user) return true + const target = resolvePostLogin(route.queryParamMap.get('redirect'), user.role, { app: document.location.origin, site: siteUrl }) + if (target.kind === 'external') { + document.location.assign(target.url) + return false + } + return router.parseUrl(target.url) +} diff --git a/apps/private/src/app/core/auth.interceptor.ts b/apps/private/src/app/core/auth.interceptor.ts new file mode 100644 index 0000000..97b6806 --- /dev/null +++ b/apps/private/src/app/core/auth.interceptor.ts @@ -0,0 +1,44 @@ +import { HttpErrorResponse, type HttpInterceptorFn, type HttpRequest } from '@angular/common/http' +import { inject } from '@angular/core' +import { catchError, from, switchMap, throwError } from 'rxjs' +import { SKIP_AUTH } from './api.service' +import { APP_CONFIG } from './app-config' +import { AuthStore } from './auth.store' + +/** + * Autentica le richieste verso l'API (mai verso altri host, il token non deve uscire dall'API): + * + * 1. aggiunge `Authorization: Bearer …` con il token in memoria; se è scaduto e la sessione è attiva, prima + * fa un refresh. Le richieste parallele attendono lo **stesso** refresh (AuthStore.refresh è deduplicato); + * 2. su 401 di una richiesta partita col token: un refresh (o il token già rinnovato da un'altra richiesta) + * e **un solo** nuovo tentativo; + * 3. refresh rifiutato → `sessionExpired()`: stato azzerato e login, con ritorno alla pagina corrente. + * + * Escluse le richieste con `SKIP_AUTH` (login, refresh, logout, registrazione, reset…). + */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { + if (req.context.get(SKIP_AUTH) || !req.url.startsWith(inject(APP_CONFIG).apiUrl)) return next(req) + const auth = inject(AuthStore) + + const withToken = (request: HttpRequest, token: string | null) => + token ? request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : request + + return from(auth.validToken()).pipe( + switchMap((token) => + next(withToken(req, token)).pipe( + catchError((err: unknown) => { + if (!token || !(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err) + return from(auth.renewAfterUnauthorized(token)).pipe( + switchMap((renewed) => { + if (!renewed) { + auth.sessionExpired() + return throwError(() => err) + } + return next(withToken(req, renewed)) + }), + ) + }), + ), + ), + ) +} diff --git a/apps/private/src/app/core/auth.spec.ts b/apps/private/src/app/core/auth.spec.ts new file mode 100644 index 0000000..b22a426 --- /dev/null +++ b/apps/private/src/app/core/auth.spec.ts @@ -0,0 +1,284 @@ +import type { UserDto, UserRole } from '@iti/shared' +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http' +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { provideRouter, Router, withComponentInputBinding } from '@angular/router' +import { RouterTestingHarness } from '@angular/router/testing' +import { firstValueFrom } from 'rxjs' +import { routes } from '../app.routes' +import { registerBody } from '../pages/auth/register' +import { ApiError, ApiService } from './api.service' +import { APP_CONFIG, parseRuntimeConfig } from './app-config' +import { authInterceptor } from './auth.interceptor' +import { AuthStore } from './auth.store' +import { resolvePostLogin } from './redirect' + +const API = 'http://api.test' +const SITE = 'http://sito.test' +const APP = 'http://localhost:3000' +const tick = () => new Promise((resolve) => setTimeout(resolve)) + +const userWith = (role: UserRole): UserDto => ({ + id: `u-${role}`, + email: `${role}@example.com`, + role, + firstName: 'Ada', + lastName: 'Rossi', + companyName: null, + vatNumber: null, + emailVerified: true, + createdAt: '2026-01-01T00:00:00.000Z', +}) + +const setHint = (on: boolean) => { + document.cookie = on ? 'iti_session=1; path=/' : 'iti_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' +} + +describe('area riservata: sessione, interceptor e guard', () => { + let http: HttpTestingController + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + provideRouter(routes, withComponentInputBinding()), + { provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: SITE, medusaAdminUrl: 'http://localhost:9000/app' } }, + ], + }) + http = TestBed.inject(HttpTestingController) + }) + afterEach(() => { + http.verify() + setHint(false) + }) + + /** Sessione ritrovata col cookie: `expiresIn` sotto il margine di 30 s = token già "scaduto". */ + async function restore(role: UserRole = 'customer', expiresIn = 900, token = 'tok-1'): Promise { + setHint(true) + const auth = TestBed.inject(AuthStore) + const ready = auth.ready() + const req = http.expectOne(`${API}/auth/refresh`) + expect(req.request.withCredentials).toBe(true) + req.flush({ accessToken: token, expiresIn, user: userWith(role) }) + await ready + return auth + } + + it('senza il cookie indizio: anonimo, nessuna richiesta', async () => { + const auth = TestBed.inject(AuthStore) + await auth.ready() + expect(auth.status()).toBe('anonymous') + http.expectNone(`${API}/auth/refresh`) + }) + + it('refresh della pagina: sessione ritrovata dal cookie, token solo in memoria', async () => { + const auth = await restore('company') + expect(auth.status()).toBe('authenticated') + expect(auth.role()).toBe('company') + expect(auth.currentToken()).toBe('tok-1') + expect(JSON.stringify({ ...localStorage })).not.toContain('tok-1') + expect(JSON.stringify({ ...sessionStorage })).not.toContain('tok-1') + }) + + it('refresh rifiutato all’avvio: anonimo', async () => { + setHint(true) + const auth = TestBed.inject(AuthStore) + const ready = auth.ready() + http.expectOne(`${API}/auth/refresh`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' }) + await ready + expect(auth.status()).toBe('anonymous') + }) + + it('aggiunge il bearer solo verso l’API', async () => { + await restore() + const api = TestBed.inject(ApiService) + const p = firstValueFrom(api.get('/me')) + await tick() + const req = http.expectOne(`${API}/me`) + expect(req.request.headers.get('Authorization')).toBe('Bearer tok-1') + req.flush({}) + await p + + const httpClient = TestBed.inject(HttpClient) + const other = firstValueFrom(httpClient.get('https://altro.example.com/x')) + const ext = http.expectOne('https://altro.example.com/x') + expect(ext.request.headers.has('Authorization')).toBe(false) + ext.flush({}) + await other + }) + + it('token scaduto: tre richieste in parallelo, un solo refresh, poi tutte col nuovo token', async () => { + await restore('customer', 10, 'vecchio') + const api = TestBed.inject(ApiService) + const results = Promise.all(['/me/orders', '/me/quotes', '/me/tickets'].map((p) => firstValueFrom(api.get(p)))) + await tick() + + const refreshes = http.match(`${API}/auth/refresh`) + expect(refreshes).toHaveLength(1) + refreshes[0]!.flush({ accessToken: 'nuovo', expiresIn: 900, user: userWith('customer') }) + await tick() + + for (const path of ['/me/orders', '/me/quotes', '/me/tickets']) { + const req = http.expectOne(`${API}${path}`) + expect(req.request.headers.get('Authorization')).toBe('Bearer nuovo') + req.flush({ ok: path }) + } + expect(await results).toHaveLength(3) + }) + + it('401 su richieste parallele: un solo refresh e un solo nuovo tentativo ciascuna', async () => { + await restore('customer', 900, 'revocato') + const api = TestBed.inject(ApiService) + const results = Promise.all([firstValueFrom(api.get('/a')), firstValueFrom(api.get('/b'))]) + await tick() + for (const path of ['/a', '/b']) { + http.expectOne(`${API}${path}`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' }) + } + await tick() + const refreshes = http.match(`${API}/auth/refresh`) + expect(refreshes).toHaveLength(1) + refreshes[0]!.flush({ accessToken: 'rinnovato', expiresIn: 900, user: userWith('customer') }) + await tick() + for (const path of ['/a', '/b']) { + const retry = http.expectOne(`${API}${path}`) + expect(retry.request.headers.get('Authorization')).toBe('Bearer rinnovato') + retry.flush({ path }) + } + expect(await results).toEqual([{ path: '/a' }, { path: '/b' }]) + }) + + it('refresh fallito dopo un 401: logout e login con ritorno alla pagina', async () => { + const auth = await restore() + const router = TestBed.inject(Router) + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true) + const api = TestBed.inject(ApiService) + const p = firstValueFrom(api.get('/me/orders')) + await tick() + http.expectOne(`${API}/me/orders`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' }) + await tick() + http.expectOne(`${API}/auth/refresh`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' }) + await expect(p).rejects.toBeInstanceOf(ApiError) + expect(auth.status()).toBe('anonymous') + expect(navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({ queryParams: expect.objectContaining({ scaduta: 1 }) })) + }) + + it('login: credenziali con cookie, niente token all’interceptor', async () => { + const auth = TestBed.inject(AuthStore) + const p = auth.login('ada@example.com', 'password-lunga') + const req = http.expectOne(`${API}/auth/login`) + expect(req.request.withCredentials).toBe(true) + expect(req.request.headers.has('Authorization')).toBe(false) + req.flush({ accessToken: 't', expiresIn: 900, user: userWith('installer') }) + expect((await p).role).toBe('installer') + expect(auth.area()).toBe('installer') + }) + + describe('guard per ruolo', () => { + // Le pagine raggiunte caricano i loro dati: qui interessa solo dove porta la navigazione + afterEach(() => http.match(() => true)) + + it('un customer che apre /admin viene riportato alla sua area', async () => { + await restore('customer') + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/admin/utenti') + expect(TestBed.inject(Router).url).toBe('/') + expect(harness.routeNativeElement?.textContent).toContain('Panoramica') + }) + + it('un admin che apre una pagina clienti va a /admin', async () => { + await restore('admin') + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/ordini') + expect(TestBed.inject(Router).url).toBe('/admin') + }) + + it('un installatore sulla radice va a /installatori, ma vede ordini e preventivi', async () => { + await restore('installer') + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/preventivi/123') + expect(TestBed.inject(Router).url).toBe('/preventivi/123') + await harness.navigateByUrl('/') + expect(TestBed.inject(Router).url).toBe('/installatori') + }) + + it('un customer non entra nell’area installatori', async () => { + await restore('company') + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/installatori/listino') + expect(TestBed.inject(Router).url).toBe('/') + }) + + it('anonimo: login con la pagina richiesta come redirect', async () => { + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/admin/blog') + expect(TestBed.inject(Router).url).toBe('/login?redirect=%2Fadmin%2Fblog') + await harness.navigateByUrl('/ordini') + expect(TestBed.inject(Router).url).toBe('/login?redirect=%2Fordini') + }) + + it('utente già autenticato sul login: va alla sua area', async () => { + await restore('admin') + const harness = await RouterTestingHarness.create() + await harness.navigateByUrl('/login') + expect(TestBed.inject(Router).url).toBe('/admin') + }) + }) +}) + +describe('redirect dopo il login', () => { + const origins = { app: APP, site: SITE } + + it('percorsi interni e URL dell’app', () => { + expect(resolvePostLogin('/preventivi/1?x=2', 'customer', origins)).toEqual({ kind: 'internal', url: '/preventivi/1?x=2' }) + expect(resolvePostLogin(`${APP}/ordini`, 'customer', origins)).toEqual({ kind: 'internal', url: '/ordini' }) + }) + + it('pagine del sito pubblico (checkout)', () => { + expect(resolvePostLogin(`${SITE}/checkout`, 'customer', origins)).toEqual({ kind: 'external', url: `${SITE}/checkout` }) + }) + + it('open redirect e valori strani → pagina iniziale del ruolo', () => { + for (const bad of ['https://evil.example.com/', '//evil.example.com', '/\\evil.example.com', 'javascript:alert(1)', 'ordini', '']) { + expect(resolvePostLogin(bad, 'installer', origins)).toEqual({ kind: 'internal', url: '/installatori' }) + } + expect(resolvePostLogin(null, 'admin', origins)).toEqual({ kind: 'internal', url: '/admin' }) + expect(resolvePostLogin('/login?redirect=/x', 'customer', origins)).toEqual({ kind: 'internal', url: '/' }) + }) +}) + +describe('configurazione e registrazione', () => { + it('config.json: URL validi senza slash finale, il resto ai default', () => { + const defaults = { apiUrl: 'http://localhost:3000', siteUrl: 'http://localhost:4200', medusaAdminUrl: 'http://localhost:9000/app' } + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(parseRuntimeConfig({ apiUrl: 'https://api.iti.it/', siteUrl: 'ftp://x' }, defaults)).toEqual({ + apiUrl: 'https://api.iti.it', + siteUrl: 'http://localhost:4200', + medusaAdminUrl: 'http://localhost:9000/app', + }) + expect(parseRuntimeConfig(null, defaults)).toEqual(defaults) + }) + + it('corpo della registrazione: campi aziendali solo se servono, niente stringhe vuote', () => { + const base = { + firstName: ' Ada ', + lastName: 'Rossi', + companyName: 'Rossi Srl', + vatNumber: '', + phone: '', + email: ' ada@example.com ', + password: 'password-lunga', + acceptMarketing: false, + } + expect(registerBody({ ...base, accountType: 'customer' })).toMatchObject({ + accountType: 'customer', + firstName: 'Ada', + email: 'ada@example.com', + companyName: undefined, + vatNumber: undefined, + phone: undefined, + acceptPrivacy: true, + }) + expect(registerBody({ ...base, accountType: 'installer' })).toMatchObject({ companyName: 'Rossi Srl', vatNumber: undefined }) + }) +}) diff --git a/apps/private/src/app/core/auth.store.ts b/apps/private/src/app/core/auth.store.ts new file mode 100644 index 0000000..1950553 --- /dev/null +++ b/apps/private/src/app/core/auth.store.ts @@ -0,0 +1,233 @@ +import type { UserDto } from '@iti/shared' +import { DOCUMENT } from '@angular/common' +import { HttpClient, HttpContext } from '@angular/common/http' +import { computed, inject, Injectable, signal } from '@angular/core' +import { Router } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { SKIP_AUTH, toApiError } from './api.service' +import { APP_CONFIG } from './app-config' +import { resolvePostLogin } from './redirect' +import { areaOf } from './roles' + +/** 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: lo stesso dell'app pubblica (apps/public). */ +export const REFRESH_LOCK = 'iti-auth-refresh' + +export interface SessionResponse { + accessToken: string + expiresIn: number + user: UserDto +} + +export type AuthStatus = 'unknown' | 'anonymous' | 'authenticated' + +/** + * Sessione dell'area riservata. + * + * - **Access token solo in memoria** (mai localStorage/sessionStorage): un refresh della pagina lo perde e la + * sessione si ritrova con `POST /auth/refresh`, che usa il cookie httpOnly `iti_rt`. + * - All'avvio il refresh parte solo se c'è il cookie indizio `iti_session` (impostato e cancellato dall'API + * insieme a `iti_rt`): senza, l'utente è anonimo e non parte nessuna richiesta destinata a un 401. + * - **Un solo refresh alla volta**: le richieste concorrenti condividono la stessa promise, e tra le schede i + * refresh sono serializzati da un Web Lock. Ogni refresh ruota il token e due refresh paralleli con lo + * stesso cookie verrebbero visti dall'API come riuso (sessione revocata). + */ +@Injectable({ providedIn: 'root' }) +export class AuthStore { + private readonly http = inject(HttpClient) + private readonly router = inject(Router) + private readonly document = inject(DOCUMENT) + private readonly config = inject(APP_CONFIG) + + private readonly _status = signal('unknown') + private readonly _user = signal(null) + readonly status = this._status.asReadonly() + readonly user = this._user.asReadonly() + readonly role = computed(() => this._user()?.role ?? null) + readonly area = computed(() => { + const role = this.role() + return role ? areaOf(role) : null + }) + readonly isAuthenticated = computed(() => this._status() === 'authenticated') + + private token: { value: string; expiresAt: number } | null = null + private initialized: Promise | null = null + private refreshing: Promise | null = null + private redirectingToLogin = false + + /** Si risolve quando lo stato iniziale della sessione è noto. */ + ready(): Promise { + 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 + } + + /** + * Token da usare per una richiesta: quello in memoria se valido, altrimenti (sessione attiva ma token + * scaduto) dopo un refresh condiviso. Null per un utente anonimo. + */ + async validToken(): Promise { + if (this._status() === 'unknown') await this.ready() + const token = this.currentToken() + if (token || this._status() !== 'authenticated') return token + return (await this.refresh()) ? this.currentToken() : null + } + + /** + * Nuovo token dopo un 401 ottenuto con `rejected`. Se nel frattempo un'altra richiesta ha già rinnovato il + * token, si usa quello senza un altro refresh. Null se la sessione non c'è più. + */ + async renewAfterUnauthorized(rejected: string): Promise { + const current = this.currentToken() + if (current && current !== rejected) return current + return (await this.refresh()) ? this.currentToken() : null + } + + /** Nuovo access token dal cookie di refresh (deduplicato); false se la sessione non c'è più. */ + refresh(): Promise { + this.refreshing ??= this.withLock(() => this.doRefresh()).finally(() => (this.refreshing = null)) + return this.refreshing + } + + async login(email: string, password: string): Promise { + try { + const res = await firstValueFrom( + this.http.post(this.url('/auth/login'), { email, password }, this.authOptions()), + ) + this.setSession(res) + return res.user + } catch (err) { + throw toApiError(err) + } + } + + /** Chiude la sessione su questo dispositivo e torna al login. Lo stato locale si azzera anche con l'API giù. */ + async logout(): Promise { + try { + await firstValueFrom(this.http.post(this.url('/auth/logout'), null, this.authOptions())) + } catch { + // il cookie resta, ma senza access token l'utente è comunque uscito da quest'app + } + this.clear() + await this.router.navigateByUrl('/login') + } + + /** Utente aggiornato da una risposta dell'API (es. `PATCH /me`). */ + setUser(user: UserDto): void { + if (this.isAuthenticated()) this._user.set(user) + } + + /** + * Nuova sessione restituita dall'API senza passare dal login (cambio password: le sessioni precedenti sono + * state chiuse e il cookie di refresh sostituito). + */ + applySession(res: SessionResponse): void { + this.setSession(res) + } + + /** Dimentica la sessione locale senza chiamare l'API (es. account appena disattivato). */ + forget(): void { + this.clear() + } + + /** Rilegge l'utente da `GET /me` (es. dopo la verifica dell'email). */ + async reloadUser(): Promise { + if (!this.isAuthenticated()) return + const token = await this.validToken() + if (!token) return + const user = await firstValueFrom( + this.http.get(this.url('/me'), { headers: { Authorization: `Bearer ${token}` }, context: this.skipAuth() }), + ) + this._user.set(user) + } + + /** + * Sessione persa durante l'uso (refresh rifiutato): stato azzerato e login, con ritorno alla pagina corrente. + * Chiamato dall'interceptor: più richieste fallite insieme producono una sola navigazione. + */ + sessionExpired(): void { + this.clear() + const current = this.router.url + if (this.redirectingToLogin || current.startsWith('/login')) return + this.redirectingToLogin = true + void this.router + .navigate(['/login'], { queryParams: { redirect: current && current !== '/' ? current : null, scaduta: 1 } }) + .finally(() => (this.redirectingToLogin = false)) + } + + /** Naviga alla destinazione del `?redirect=` validato, oppure alla pagina iniziale del ruolo. */ + async navigateAfterLogin(redirect: string | null): Promise { + const user = this._user() + if (!user) return + const target = resolvePostLogin(redirect, user.role, { app: this.document.location.origin, site: this.config.siteUrl }) + if (target.kind === 'external') { + this.document.location.assign(target.url) + return + } + await this.router.navigateByUrl(target.url, { replaceUrl: true }) + } + + private async init(): Promise { + if (!this.hasHintCookie()) { + this._status.set('anonymous') + return + } + await this.refresh() + } + + private async doRefresh(): Promise { + try { + const res = await firstValueFrom(this.http.post(this.url('/auth/refresh'), null, this.authOptions())) + this.setSession(res) + return true + } catch { + // 401 (sessione scaduta o revocata: l'API cancella anche i cookie), rete, rate limit + this.clear() + return false + } + } + + private setSession(res: SessionResponse): void { + this.token = { value: res.accessToken, expiresAt: Date.now() + res.expiresIn * 1000 } + this._user.set(res.user) + this._status.set('authenticated') + } + + private clear(): void { + this.token = null + this._user.set(null) + this._status.set('anonymous') + } + + private url(path: string): string { + return `${this.config.apiUrl}${path}` + } + + private skipAuth(): HttpContext { + return new HttpContext().set(SKIP_AUTH, true) + } + + /** Cookie inviati e ricevuti (refresh token, carrello) e nessun intervento dell'interceptor. */ + private authOptions() { + return { withCredentials: true, context: this.skipAuth() } + } + + 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(fn: () => Promise): Promise { + const locks = (globalThis.navigator as Navigator | undefined)?.locks + return locks ? locks.request(REFRESH_LOCK, fn) : fn() + } +} diff --git a/apps/private/src/app/core/download.service.ts b/apps/private/src/app/core/download.service.ts new file mode 100644 index 0000000..268a13c --- /dev/null +++ b/apps/private/src/app/core/download.service.ts @@ -0,0 +1,73 @@ +import { DOCUMENT } from '@angular/common' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { firstValueFrom } from 'rxjs' +import { ApiError, ApiService, toApiError } from './api.service' + +const EXTENSIONS: Record = { + 'application/pdf': '.pdf', + 'application/zip': '.zip', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/vnd.dwg': '.dwg', + 'image/vnd.dxf': '.dxf', +} + +/** Nome di file sicuro da un titolo ("Scheda: pompa 8 kW" → "Scheda pompa 8 kW.pdf"). */ +export function fileName(title: string, mimeType: string): string { + const base = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'documento' + const ext = EXTENSIONS[mimeType] ?? '' + return base.toLowerCase().endsWith(ext) ? base : `${base}${ext}` +} + +/** + * Download dei file protetti (`/documents/:id/download`, `/me/quotes/:id/pdf`). Un semplice link non + * porterebbe l'access token (che sta solo in memoria): il file si scarica con HttpClient (interceptor → + * Authorization), poi si salva con un link a un object URL. + */ +@Injectable({ providedIn: 'root' }) +export class DownloadService { + private readonly http = inject(HttpClient) + private readonly api = inject(ApiService) + private readonly document = inject(DOCUMENT) + + async download(path: string, name: string): Promise { + let blob: Blob + try { + blob = await firstValueFrom(this.http.get(this.api.url(path), { responseType: 'blob' })) + } catch (err) { + throw await this.blobError(err) + } + const url = URL.createObjectURL(blob) + try { + const link = this.document.createElement('a') + link.href = url + link.download = name + link.rel = 'noopener' + this.document.body.append(link) + link.click() + link.remove() + } finally { + // il browser ha già avviato il salvataggio: l'URL si può liberare al prossimo giro + setTimeout(() => URL.revokeObjectURL(url), 1000) + } + } + + /** Con `responseType: 'blob'` anche il corpo dell'errore è un Blob: lo si rilegge come JSON. */ + private async blobError(err: unknown): Promise { + const body = (err as { error?: unknown }).error + if (body instanceof Blob) { + try { + const parsed = JSON.parse(await body.text()) as { error?: { code?: string; message?: string } } + if (parsed.error?.code) { + return new ApiError((err as { status?: number }).status ?? 0, parsed.error.code, parsed.error.message ?? 'Errore') + } + } catch { + // corpo non JSON: errore generico sotto + } + } + return toApiError(err) + } +} diff --git a/apps/private/src/app/core/format.ts b/apps/private/src/app/core/format.ts new file mode 100644 index 0000000..86fae05 --- /dev/null +++ b/apps/private/src/app/core/format.ts @@ -0,0 +1,50 @@ +/** 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` +} + +const dateTime = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Rome', +}) + +/** Data e ora ISO → "3 ott 2026, 18:40" (fuso Europe/Rome). */ +export function formatDateTime(iso: string): string { + return dateTime.format(new Date(iso)) +} + +/** Data senza ora (YYYY-MM-DD, es. `validUntil` dei preventivi) → "31 ottobre 2026", senza slittamenti di fuso. */ +export function formatDay(ymd: string): string { + return longDate.format(new Date(`${ymd}T12:00:00Z`)) +} diff --git a/apps/private/src/app/core/installer-profile.store.ts b/apps/private/src/app/core/installer-profile.store.ts new file mode 100644 index 0000000..feb6c1f --- /dev/null +++ b/apps/private/src/app/core/installer-profile.store.ts @@ -0,0 +1,71 @@ +import type { InstallerProfileDto, InstallerStatus } from '@iti/shared' +import { computed, inject, Injectable } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { catchError, firstValueFrom, of, throwError } from 'rxjs' +import { ApiError, ApiService } from './api.service' +import { AuthStore } from './auth.store' + +/** Corpo di `PUT /installer/profile`. */ +export interface InstallerProfileInput { + businessName: string + vatNumber: string | null + phone: string | null + address: string | null + location: { lat: number; lng: number } | null + serviceRadiusKm: number + certifications: string[] +} + +/** Stato dell'installatore per l'interfaccia: `missing` = profilo non ancora creato. */ +export type InstallerState = InstallerStatus | 'missing' + +/** Dati del profilo che mancano per una valutazione completa (in ordine di importanza). */ +export function missingProfileFields(p: InstallerProfileDto | null): string[] { + if (!p) return ['ragione sociale', 'partita IVA', 'telefono', 'indirizzo della sede', 'posizione sulla mappa'] + const missing: string[] = [] + if (!p.vatNumber) missing.push('partita IVA') + if (!p.phone) missing.push('telefono') + if (!p.address) missing.push('indirizzo della sede') + if (!p.location) missing.push('posizione sulla mappa') + if (p.certifications.length === 0) missing.push('certificazioni') + return missing +} + +/** + * Profilo dell'installatore autenticato (`GET /installer/profile`), condiviso tra home, profilo, listino e + * documentazione: lo stato (`pending`, `approved`, `rejected`) decide cosa mostrare. Il controllo vero resta + * nell'API, che dà listino e documenti tecnici solo agli approvati. 404 = profilo non ancora creato. + */ +@Injectable({ providedIn: 'root' }) +export class InstallerProfileStore { + private readonly api = inject(ApiService) + private readonly auth = inject(AuthStore) + + readonly resource = rxResource({ + params: () => { + const user = this.auth.user() + return user?.role === 'installer' ? user.id : undefined + }, + stream: () => + this.api + .get('/installer/profile') + .pipe(catchError((err: unknown) => (err instanceof ApiError && err.status === 404 ? of(null) : throwError(() => err)))), + }) + + readonly profile = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined)) + readonly state = computed(() => { + const p = this.profile() + return p === undefined ? null : p === null ? 'missing' : p.status + }) + readonly approved = computed(() => this.state() === 'approved') + readonly missing = computed(() => { + const p = this.profile() + return p === undefined ? [] : missingProfileFields(p) + }) + + async save(input: InstallerProfileInput): Promise { + const saved = await firstValueFrom(this.api.put('/installer/profile', input)) + this.resource.set(saved) + return saved + } +} diff --git a/apps/private/src/app/core/labels.ts b/apps/private/src/app/core/labels.ts new file mode 100644 index 0000000..1929c2a --- /dev/null +++ b/apps/private/src/app/core/labels.ts @@ -0,0 +1,152 @@ +import type { + BlogPostStatus, + ContactRequestStatus, + DocumentVisibility, + InstallerStatus, + MyOrderDto, + QuoteRequestStatus, + QuoteStatus, + ShowroomBookingStatus, + TicketPriority, + TicketStatus, +} from '@iti/shared' + +/** Tono del badge di stato (colore): neutro, in corso, positivo, da fare, negativo. */ +export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' + +export interface StatusLabel { + label: string + tone: Tone +} + +/** + * Stato di un ordine in una sola etichetta per il cliente, dagli stati Medusa (ordine, pagamento, spedizione). + * Priorità: annullato > consegnato > spedito > preparato > pagamento. + */ +export function orderStatus(order: Pick): StatusLabel { + if (order.status === 'canceled') return { label: 'Annullato', tone: 'danger' } + switch (order.fulfillmentStatus) { + case 'delivered': + return { label: 'Consegnato', tone: 'success' } + case 'partially_delivered': + return { label: 'Consegnato in parte', tone: 'info' } + case 'shipped': + return { label: 'Spedito', tone: 'info' } + case 'partially_shipped': + return { label: 'Spedito in parte', tone: 'info' } + case 'fulfilled': + case 'partially_fulfilled': + return { label: 'In preparazione', tone: 'info' } + } + if (order.status === 'completed') return { label: 'Completato', tone: 'success' } + switch (order.paymentStatus) { + case 'captured': + case 'authorized': + case 'partially_captured': + case 'partially_authorized': + return { label: 'Confermato', tone: 'info' } + case 'refunded': + return { label: 'Rimborsato', tone: 'neutral' } + case 'partially_refunded': + return { label: 'Rimborsato in parte', tone: 'neutral' } + case 'requires_action': + return { label: 'Pagamento da completare', tone: 'warning' } + default: + return { label: 'In attesa di pagamento', tone: 'warning' } + } +} + +export const QUOTE_STATUS: Record = { + draft: { label: 'In preparazione', tone: 'neutral' }, + sent: { label: 'Da valutare', tone: 'warning' }, + accepted: { label: 'Accettato', tone: 'success' }, + rejected: { label: 'Rifiutato', tone: 'neutral' }, + expired: { label: 'Scaduto', tone: 'danger' }, +} + +export const TICKET_STATUS: Record = { + open: { label: 'Aperto', tone: 'info' }, + in_progress: { label: 'In lavorazione', tone: 'info' }, + waiting_customer: { label: 'In attesa di tua risposta', tone: 'warning' }, + closed: { label: 'Chiuso', tone: 'neutral' }, +} + +export const TICKET_PRIORITY: Record = { + low: 'Bassa', + normal: 'Normale', + high: 'Alta', + urgent: 'Urgente', +} + +/** Preventivo a cui il cliente può ancora rispondere (l'API mostra già come `expired` quelli scaduti). */ +export const isQuotePending = (status: QuoteStatus) => status === 'sent' + +/** Ticket non chiuso (in dashboard tra quelli aperti). */ +export const isTicketOpen = (status: TicketStatus) => status !== 'closed' + +// --- Pannello admin --- + +/** Stato dei ticket visto dallo staff ("in attesa del cliente" invece di "in attesa di tua risposta"). */ +export const ADMIN_TICKET_STATUS: Record = { + open: { label: 'Da gestire', tone: 'warning' }, + in_progress: { label: 'In lavorazione', tone: 'info' }, + waiting_customer: { label: 'In attesa del cliente', tone: 'neutral' }, + closed: { label: 'Chiuso', tone: 'neutral' }, +} + +/** Stato dei preventivi visto dallo staff. */ +export const ADMIN_QUOTE_STATUS: Record = { + draft: { label: 'Bozza', tone: 'neutral' }, + sent: { label: 'Inviato', tone: 'info' }, + accepted: { label: 'Accettato', tone: 'success' }, + rejected: { label: 'Rifiutato', tone: 'danger' }, + expired: { label: 'Scaduto', tone: 'warning' }, +} + +export const QUOTE_REQUEST_STATUS: Record = { + new: { label: 'Nuova', tone: 'warning' }, + contacted: { label: 'Contattato', tone: 'info' }, + converted: { label: 'Preventivo inviato', tone: 'success' }, + closed: { label: 'Chiusa', tone: 'neutral' }, +} + +export const INSTALLER_STATUS: Record = { + pending: { label: 'In attesa', tone: 'warning' }, + approved: { label: 'Approvato', tone: 'success' }, + rejected: { label: 'Rifiutato', tone: 'danger' }, +} + +export const CONTACT_STATUS: Record = { + new: { label: 'Nuova', tone: 'warning' }, + in_progress: { label: 'In gestione', tone: 'info' }, + closed: { label: 'Chiusa', tone: 'neutral' }, +} + +export const BOOKING_STATUS: Record = { + requested: { label: 'Da confermare', tone: 'warning' }, + confirmed: { label: 'Confermata', tone: 'info' }, + cancelled: { label: 'Annullata', tone: 'neutral' }, + completed: { label: 'Completata', tone: 'success' }, +} + +export const BLOG_STATUS: Record = { + draft: { label: 'Bozza', tone: 'neutral' }, + published: { label: 'Pubblicato', tone: 'success' }, +} + +/** Chi può vedere un documento. */ +export const VISIBILITY_LABELS: Record = { + public: 'Tutti (anche senza account)', + customer: 'Clienti registrati', + installer: 'Installatori approvati', + admin: 'Solo staff', +} + +/** Utente attivo o disabilitato. */ +export const accountStatus = (disabledAt: string | null): StatusLabel => + disabledAt ? { label: 'Disabilitato', tone: 'danger' } : { label: 'Attivo', tone: 'success' } + +/** Opzioni di un ` + + + +

    + Markdown: ## Titolo, **grassetto**, _corsivo_, - elenco, + [testo](https://…). Scorciatoie Ctrl+B, Ctrl+I, Ctrl+K. +

    + + +
    +

    + Anteprima + @switch (preview().status) { + @case ('loading') { + · aggiornamento… + } + @case ('error') { + · non disponibile, riprova tra poco + } + } +

    +
    +

    {{ values().title || 'Titolo dell’articolo' }}

    + @if (values().excerpt) { +

    {{ values().excerpt }}

    + } + + @if (cover(); as c) { +
    + } + @if (values().contentMd.trim()) { +
    + } @else { +

    Il testo che scrivi comparirà qui come sul sito.

    + } +
    +
    + + + + + + + + + +} diff --git a/apps/private/src/app/pages/admin/blog-editor.scss b/apps/private/src/app/pages/admin/blog-editor.scss new file mode 100644 index 0000000..a5d9508 --- /dev/null +++ b/apps/private/src/app/pages/admin/blog-editor.scss @@ -0,0 +1,241 @@ +@use 'mixins' as mq; +@use 'prose'; + +.layout { + display: grid; + gap: var(--space-5); + + @include mq.up(xl) { + grid-template-columns: minmax(0, 1fr) 22rem; + align-items: start; + } +} + +.main { + display: grid; + gap: var(--space-5); + min-width: 0; +} + +.fields { + display: grid; + gap: var(--space-4); +} + +.side { + display: grid; + gap: var(--space-4); + min-width: 0; + + // i campi data e file hanno una larghezza intrinseca: senza questi limiti allargano la colonna + > * { + min-width: 0; + } + input:not([type='radio']), + select { + width: 100%; + min-width: 0; + } + + h2 { + font-size: var(--font-size-lg); + } + .form-field + .form-field, + app-form-field + app-form-field { + margin-top: var(--space-4); + } + .actions { + margin-block: var(--space-4) var(--space-2); + } + .label { + font-weight: var(--font-weight-semibold); + } + fieldset { + margin-bottom: var(--space-3); + } +} + +.file-label { + margin-top: var(--space-2); + font-weight: var(--font-weight-regular); +} + +.cover-thumb { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--radius-md); + background: var(--color-surface-alt); +} + +// --- Editor + anteprima --- + +.editor { + padding: 0; + overflow: hidden; + // scrittura e anteprima affiancate solo se c'è spazio per entrambe (dipende dal pannello, non dalla finestra) + container-type: inline-size; +} + +.tabs { + display: flex; + border-bottom: 1px solid var(--color-line); + + button { + flex: 1; + min-height: 44px; + border: 0; + border-bottom: 3px solid transparent; + background: none; + font: inherit; + font-weight: var(--font-weight-semibold); + color: var(--color-ink-muted); + cursor: pointer; + + &[aria-selected='true'] { + border-bottom-color: var(--color-primary); + color: var(--color-primary-strong); + } + @include mq.focus-ring; + } + + // scrittura e anteprima affiancate: le schede non servono + @container (min-width: 56rem) { + display: none; + } +} + +.panes { + @container (min-width: 56rem) { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + +.pane { + min-width: 0; + padding: var(--space-4); + + &[data-active='false'] { + display: none; + } + @container (min-width: 56rem) { + &[data-active='false'] { + display: block; + } + & + & { + border-left: 1px solid var(--color-line); + } + } +} + +.toolbar-md { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-2); +} + +.tool { + min-width: 44px; + min-height: 40px; + padding: 0 var(--space-3); + border: 1px solid var(--color-line); + border-radius: var(--radius-sm); + background: var(--color-surface); + font: inherit; + font-size: var(--font-size-sm); + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } + &--bold { + font-weight: var(--font-weight-bold); + } + &--italic { + font-style: italic; + } + @include mq.focus-ring; +} + +textarea { + display: block; + width: 100%; + min-height: 28rem; + padding: var(--space-3); + border: 1px solid var(--color-ink-muted); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.6; + resize: vertical; +} + +.hint { + margin: var(--space-2) 0 0; + font-size: var(--font-size-sm); + color: var(--color-ink-muted); + + code { + font-family: var(--font-mono); + } +} + +// Anteprima: stessa tipografia della pagina articolo del sito (packages/styles/_prose.scss) +.preview { + max-height: 44rem; + overflow-y: auto; +} + +.preview-label { + margin: 0 0 var(--space-3); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-ink-muted); +} + +.post { + &__title { + font-size: var(--font-size-4xl); + margin-bottom: var(--space-3); + } + &__lead { + font-size: var(--font-size-lg); + color: var(--color-ink-muted); + } + &__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + font-size: var(--font-size-sm); + color: var(--color-ink-muted); + } + &__category { + font-weight: var(--font-weight-semibold); + color: var(--color-secondary); + } + &__cover { + aspect-ratio: 16 / 9; + margin-bottom: var(--space-6); + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--color-surface-alt); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } +} + +.prose { + @include prose.container; +} + +.prose ::ng-deep { + @include prose.content; +} diff --git a/apps/private/src/app/pages/admin/blog-editor.ts b/apps/private/src/app/pages/admin/blog-editor.ts new file mode 100644 index 0000000..301accd --- /dev/null +++ b/apps/private/src/app/pages/admin/blog-editor.ts @@ -0,0 +1,330 @@ +import type { BlogCategoryDto, BlogPostAdminDto, BlogPostStatus } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, type ElementRef, inject, input, linkedSignal, signal, viewChild } from '@angular/core' +import { rxResource, toObservable, toSignal } from '@angular/core/rxjs-interop' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { DomSanitizer } from '@angular/platform-browser' +import { Router, RouterLink } from '@angular/router' +import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, map, of, startWith, switchMap } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatDate } from '../../core/format' +import { Badge } from '../../shared/badge' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, isNotFound } from './admin-common' +import { postStatus } from './blog' +import { applyMarkdown, fromLocalInput, insertImage, type MarkdownAction, type TextEdit, toLocalInput } from './markdown-edit' +import { multipart, uploadErrorMessage } from './upload' + +/** Attesa dopo l'ultima modifica prima di chiedere l'anteprima all'API. */ +export const PREVIEW_DEBOUNCE_MS = 400 +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const IMAGE_ACCEPT = '.jpg,.jpeg,.png,.webp,.gif' + +type Upload = { url: string; path: string } +type PreviewState = { status: 'idle' | 'loading' | 'ready' | 'error'; html: string } + +function editorForm() { + return new FormGroup({ + title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(200)] }), + slug: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(80), Validators.pattern(SLUG)] }), + excerpt: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(500)] }), + categoryId: new FormControl('', { nonNullable: true }), + contentMd: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200_000)] }), + seoTitle: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(70)] }), + seoDescription: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(160)] }), + status: new FormControl('draft', { nonNullable: true }), + publishedAt: new FormControl('', { nonNullable: true }), + }) +} +type EditorValue = ReturnType['getRawValue']> + +/** + * Corpo di POST/PATCH /admin/blog/posts. Vuoti → null (cancellano il valore). Lo slug va solo se scritto + * (creazione: altrimenti lo genera l'API dal titolo) o cambiato (modifica: gli URL restano stabili). + */ +export function postBody(v: EditorValue, coverPath: string | null, current: BlogPostAdminDto | null) { + const slug = v.slug.trim() + return { + title: v.title.trim(), + ...(slug && slug !== current?.slug ? { slug } : {}), + excerpt: v.excerpt.trim() || null, + categoryId: v.categoryId || null, + contentMd: v.contentMd, + coverPath, + seoTitle: v.seoTitle.trim() || null, + seoDescription: v.seoDescription.trim() || null, + status: v.status, + publishedAt: fromLocalInput(v.publishedAt), + } +} + +/** + * Editor degli articoli (`/admin/blog/nuovo`, `/admin/blog/:id`): markdown in una textarea con una barra minima + * (grassetto, corsivo, titoli, elenco, citazione, link, immagine caricata), anteprima live con lo stesso HTML + * e gli stessi stili della pagina pubblica (`POST /admin/blog/preview` dopo una pausa di 400 ms), copertina, + * campi SEO, categoria, bozza/pubblicato e data di pubblicazione (una data futura programma l'articolo). + */ +@Component({ + selector: 'app-admin-blog-editor', + imports: [ReactiveFormsModule, RouterLink, Badge, ConfirmDialog, FormField, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './blog-editor.html', + styleUrl: './blog-editor.scss', +}) +export default class AdminBlogEditor { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly sanitizer = inject(DomSanitizer) + private readonly siteUrl = inject(APP_CONFIG).siteUrl + /** Id dell'articolo; assente su `/admin/blog/nuovo`. */ + readonly id = input() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/blog/posts/${encodeURIComponent(params)}`), + }) + protected readonly categories = rxResource({ stream: () => this.api.get('/admin/blog/categories') }) + protected readonly post = computed(() => (this.res.hasValue() ? this.res.value() : null)) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + protected readonly isNew = computed(() => !this.id()) + + protected readonly form = editorForm() + protected readonly cover = signal(null) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly uploading = signal<'cover' | 'image' | null>(null) + protected readonly notice = signal<{ ok: boolean; text: string } | null>( + (inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created + ? { ok: true, text: 'Articolo creato.' } + : null, + ) + protected readonly tab = signal<'write' | 'preview'>('write') + private readonly textarea = viewChild>('content') + private readonly imageInput = viewChild>('imageInput') + private readonly dialog = viewChild(ConfirmDialog) + + /** Valori del form come signal (anteprima di titolo, estratto, contatori SEO). */ + protected readonly values = toSignal(this.form.valueChanges.pipe(map(() => this.form.getRawValue())), { + initialValue: this.form.getRawValue(), + }) + + /** Anteprima: il markdown va all'API (stesso renderer e sanitizer del sito) dopo una pausa nella scrittura. */ + protected readonly preview = toSignal( + toObservable(computed(() => this.values().contentMd)).pipe( + debounceTime(PREVIEW_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((markdown) => + markdown.trim() === '' + ? of({ status: 'idle', html: '' }) + : this.api.post<{ html: string }>('/admin/blog/preview', { markdown }).pipe( + map(({ html }): PreviewState => ({ status: 'ready', html })), + catchError(() => of({ status: 'error', html: '' })), + startWith({ status: 'loading', html: '' }), + ), + ), + ), + { initialValue: { status: 'idle', html: '' } as PreviewState }, + ) + /** Ultimo HTML valido: resta visibile mentre arriva il successivo (niente sfarfallio). */ + private readonly lastHtml = linkedSignal({ + source: this.preview, + computation: (p, previous) => (p.status === 'ready' ? p.html : p.status === 'idle' ? '' : (previous?.value ?? '')), + }) + // Fidato: HTML sanitizzato dall'API (sanitize-html con whitelist), come nella pagina pubblica + protected readonly previewHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.lastHtml())) + + protected readonly publicUrl = computed(() => { + const p = this.post() + return p && p.status === 'published' && p.publishedAt && Date.parse(p.publishedAt) <= Date.now() ? `${this.siteUrl}/blog/${p.slug}` : null + }) + protected readonly state = computed(() => { + const p = this.post() + return p ? postStatus(p) : null + }) + protected readonly categoryName = computed(() => this.categories.value()?.find((c) => c.id === this.values().categoryId)?.name ?? null) + + protected readonly imageAccept = IMAGE_ACCEPT + protected readonly toolbar: { action: MarkdownAction; label: string; title: string }[] = [ + { action: 'bold', label: 'G', title: 'Grassetto' }, + { action: 'italic', label: 'C', title: 'Corsivo' }, + { action: 'h2', label: 'H2', title: 'Titolo di sezione' }, + { action: 'h3', label: 'H3', title: 'Sottotitolo' }, + { action: 'list', label: '• Elenco', title: 'Elenco puntato' }, + { action: 'quote', label: '❝ Citazione', title: 'Citazione' }, + { action: 'link', label: 'Link', title: 'Link' }, + ] + protected readonly date = formatDate + + constructor() { + const setTitle = adminTitle() + effect(() => { + const p = this.post() + setTitle(this.isNew() ? 'Nuovo articolo' : p ? `Modifica: ${p.title}` : 'Articolo') + }) + // Carica i dati dell'articolo nel form (anche dopo il salvataggio, con i valori normalizzati dall'API) + effect(() => { + const p = this.post() + if (!p) return + this.form.setValue({ + title: p.title, + slug: p.slug, + excerpt: p.excerpt ?? '', + categoryId: p.category?.id ?? '', + contentMd: p.contentMd, + seoTitle: p.seoTitle ?? '', + seoDescription: p.seoDescription ?? '', + status: p.status, + publishedAt: toLocalInput(p.publishedAt), + }) + this.form.markAsPristine() + const path = p.coverUrl ? coverPathFrom(p.coverUrl) : null + this.cover.set(p.coverUrl && path ? { url: p.coverUrl, path } : null) + }) + } + + // --- Barra degli strumenti --- + + protected format(action: MarkdownAction): void { + const el = this.textarea()?.nativeElement + if (!el) return + this.applyEdit(applyMarkdown(el.value, el.selectionStart, el.selectionEnd, action)) + } + + /** Scorciatoie: Ctrl/Cmd+B grassetto, Ctrl/Cmd+I corsivo, Ctrl/Cmd+K link. */ + protected shortcut(event: KeyboardEvent): void { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return + const action = ({ b: 'bold', i: 'italic', k: 'link' } as Record)[event.key.toLowerCase()] + if (!action) return + event.preventDefault() + this.format(action) + } + + protected chooseImage(): void { + this.imageInput()?.nativeElement.click() + } + + /** Carica un'immagine nell'area pubblica e la inserisce nel testo alla posizione del cursore. */ + protected async insertUploadedImage(event: Event): Promise { + const field = event.target as HTMLInputElement + const file = field.files?.[0] + const el = this.textarea()?.nativeElement + if (!file || !el) return + const { selectionStart, selectionEnd } = el + this.uploading.set('image') + this.notice.set(null) + try { + const up = await firstValueFrom(this.api.post('/admin/uploads/image', multipart(file))) + this.applyEdit(insertImage(el.value, selectionStart, selectionEnd, up.url)) + } catch (err) { + this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) }) + } finally { + field.value = '' + this.uploading.set(null) + } + } + + private applyEdit(edit: TextEdit): void { + const control = this.form.controls.contentMd + control.setValue(edit.text) + control.markAsDirty() + this.tab.set('write') + const el = this.textarea()?.nativeElement + if (!el) return + // il valore del controllo arriva nella textarea in modo sincrono (DefaultValueAccessor) + el.focus() + el.setSelectionRange(edit.start, edit.end) + } + + // --- Copertina --- + + protected async uploadCover(event: Event): Promise { + const field = event.target as HTMLInputElement + const file = field.files?.[0] + if (!file) return + this.uploading.set('cover') + this.notice.set(null) + try { + const up = await firstValueFrom(this.api.post('/admin/uploads/image', multipart(file))) + this.cover.set({ url: up.url, path: up.path }) + this.form.markAsDirty() + } catch (err) { + this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) }) + } finally { + field.value = '' + this.uploading.set(null) + } + } + + protected removeCover(): void { + this.cover.set(null) + this.form.markAsDirty() + } + + // --- Salvataggio --- + + protected async save(status?: BlogPostStatus): Promise { + if (status) this.form.controls.status.setValue(status) + this.submitted.set(true) + this.notice.set(null) + if (this.form.invalid || this.busy()) { + this.tab.set('write') + return + } + const current = this.post() + const body = postBody(this.form.getRawValue(), this.cover()?.path ?? null, current) + this.busy.set(true) + try { + if (current) { + const saved = await firstValueFrom(this.api.patch(`/admin/blog/posts/${current.id}`, body)) + this.res.set(saved) + this.submitted.set(false) + this.notice.set({ ok: true, text: savedMessage(saved) }) + } else { + const saved = await firstValueFrom(this.api.post('/admin/blog/posts', body)) + this.form.markAsPristine() + void this.router.navigate(['/admin/blog', saved.id], { replaceUrl: true, state: { created: true } }) + } + } catch (err) { + const e = toApiError(err) + if (e.code === 'SLUG_TAKEN') this.form.controls.slug.setErrors({ server: 'Indirizzo già usato da un altro articolo' }) + else if (!applyServerErrors(this.form, e)) this.notice.set({ ok: false, text: e.message }) + } finally { + this.busy.set(false) + } + } + + protected askDelete(): void { + this.dialog()?.open() + } + + protected async remove(): Promise { + const p = this.post() + if (!p || this.busy()) return + this.busy.set(true) + try { + await firstValueFrom(this.api.delete(`/admin/blog/posts/${p.id}`)) + this.dialog()?.close() + void this.router.navigate(['/admin/blog']) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + this.dialog()?.close() + } finally { + this.busy.set(false) + } + } +} + +/** `path` da rimandare all'API (`images/…`) a partire dal `coverUrl` del DTO. */ +export function coverPathFrom(url: string): string | null { + const i = url.indexOf('/images/') + return i === -1 ? null : url.slice(i + 1) +} + +function savedMessage(p: BlogPostAdminDto): string { + if (p.status === 'draft') return 'Bozza salvata: non è visibile sul sito.' + if (p.publishedAt && Date.parse(p.publishedAt) > Date.now()) return `Articolo programmato: comparirà sul sito il ${formatDate(p.publishedAt)}.` + return 'Articolo pubblicato.' +} diff --git a/apps/private/src/app/pages/admin/blog.ts b/apps/private/src/app/pages/admin/blog.ts new file mode 100644 index 0000000..be5f8a1 --- /dev/null +++ b/apps/private/src/app/pages/admin/blog.ts @@ -0,0 +1,105 @@ +import type { BlogPostAdminDto, BlogPostStatus, 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 { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { BLOG_STATUS, type StatusLabel } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common' + +const STATUSES = Object.keys(BLOG_STATUS) as BlogPostStatus[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: BLOG_STATUS[s].label }))] + +/** Stato editoriale: pubblicato con data futura = programmato (non ancora visibile sul sito). */ +export function postStatus(p: Pick, now = Date.now()): StatusLabel { + if (p.status === 'published' && p.publishedAt && Date.parse(p.publishedAt) > now) return { label: 'Programmato', tone: 'info' } + return BLOG_STATUS[p.status] +} + +/** Articoli del blog (bozze comprese), modificati di recente in cima. */ +@Component({ + selector: 'app-admin-blog', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    Blog

    + +
    +
    + + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    +
    + + + + + + + + + + + @for (p of data.items; track p.id) { + + + + + + + + } + +
    TitoloCategoriaPubblicazioneModificatoStato
    + {{ p.title }}
    + /blog/{{ p.slug }} +
    {{ p.category?.name ?? '—' }}{{ p.publishedAt ? dateTime(p.publishedAt) : '—' }}{{ dateTime(p.updatedAt) }}
    + + + } @else { + + Scrivi il primo + + } + } + `, +}) +export default class AdminBlog { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly q = input() + + protected readonly filters = FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as BlogPostStatus) : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/blog/posts', { query: { ...pageParams(params.page), status: params.status, q: params.q } }), + }) + + protected readonly state = (p: BlogPostAdminDto) => postStatus(p) + protected readonly dateTime = formatDateTime + + constructor() { + adminTitle()('Blog') + } +} diff --git a/apps/private/src/app/pages/admin/booking-detail.ts b/apps/private/src/app/pages/admin/booking-detail.ts new file mode 100644 index 0000000..32826e9 --- /dev/null +++ b/apps/private/src/app/pages/admin/booking-detail.ts @@ -0,0 +1,115 @@ +import type { ShowroomBookingAdminDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { BOOKING_STATUS, optionsOf } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, isNotFound } from './admin-common' +import { StatusSelect } from './status-select' + +/** + * Prenotazione dello showroom. Annullata/completata libera i posti; riportarla attiva ricontrolla la capienza + * (lo slot può essersi riempito: 409); il passaggio a "Confermata" invia l'email di conferma al cliente. + */ +@Component({ + selector: 'app-admin-booking-detail', + imports: [RouterLink, Badge, StatusSelect, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Prenotazioni showroom + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Prenotazione non trovata

    + + } @else if (res.error()) { +

    Prenotazione

    + + } @else if (res.value(); as b) { +
    +

    Visita del {{ dateTime(b.requestedAt) }}

    + +
    + @if (notice(); as n) { +

    {{ n.text }}

    + } +
    +
    +
    Visitatore
    +
    {{ b.name }}
    +
    Email
    +
    {{ b.email }}
    +
    Telefono
    +
    + @if (b.phone) { + {{ b.phone }} + } @else { + — + } +
    +
    Persone
    +
    {{ b.people }}
    +
    Note
    +
    {{ b.notes || '—' }}
    +
    Prenotata il
    +
    {{ dateTime(b.createdAt) }}
    + @if (b.userId) { +
    Account
    +
    Apri l'utente
    + } +
    + +

    Confermando una prenotazione il cliente riceve un'email. Annullata o completata libera i posti.

    +
    + } + `, + styles: ` + .status { + display: block; + margin-block: var(--space-5) var(--space-2); + } + `, +}) +export default class AdminBookingDetail { + private readonly api = inject(ApiService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/showroom/bookings/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + protected readonly options = optionsOf(BOOKING_STATUS) + protected readonly labels = BOOKING_STATUS + protected readonly dateTime = formatDateTime + + constructor() { + const setTitle = adminTitle() + effect(() => setTitle(this.res.value() ? `Visita di ${this.res.value()?.name}` : 'Prenotazione showroom')) + } + + protected async setStatus(b: ShowroomBookingAdminDto, status: string): Promise { + this.busy.set(true) + this.notice.set(null) + try { + const updated = await firstValueFrom(this.api.patch(`/admin/showroom/bookings/${b.id}`, { status })) + this.res.set(updated) + this.notice.set({ + ok: true, + text: status === 'confirmed' && b.status !== 'confirmed' ? `Prenotazione confermata: email inviata a ${b.email}.` : 'Stato aggiornato.', + }) + } catch (err) { + const e = toApiError(err) + this.notice.set({ ok: false, text: e.code === 'SLOT_FULL' ? 'Lo slot nel frattempo si è riempito: non si può riattivare la prenotazione.' : e.message }) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/configurator-summary.ts b/apps/private/src/app/pages/admin/configurator-summary.ts new file mode 100644 index 0000000..48dc5e4 --- /dev/null +++ b/apps/private/src/app/pages/admin/configurator-summary.ts @@ -0,0 +1,81 @@ +import type { ConfiguratorAnswers, ConfiguratorDefinition, ConfiguratorResult } from '@iti/shared' +import { formatEuro } from '../../core/format' + +/** Input salvato dal configuratore in `quote_requests.configurator_input`. */ +export interface StoredConfiguratorInput { + definitionVersion?: string + answers?: ConfiguratorAnswers + message?: string +} + +export interface AnswerRow { + label: string + value: string +} + +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) + +/** Legge l'input salvato senza fidarsi della forma (righe vecchie o scritte a mano). */ +export function readConfiguratorInput(raw: unknown): StoredConfiguratorInput { + if (!isRecord(raw)) return {} + const answers = isRecord(raw['answers']) ? (raw['answers'] as ConfiguratorAnswers) : undefined + return { + ...(typeof raw['definitionVersion'] === 'string' ? { definitionVersion: raw['definitionVersion'] } : {}), + ...(answers ? { answers } : {}), + ...(typeof raw['message'] === 'string' ? { message: raw['message'] } : {}), + } +} + +/** Risultato salvato (`configurator_output`), se ha la forma attesa. */ +export function readConfiguratorResult(raw: unknown): ConfiguratorResult | null { + return isRecord(raw) && isRecord(raw['solution']) && isRecord(raw['estimates']) ? (raw as unknown as ConfiguratorResult) : null +} + +/** + * Risposte in forma leggibile: etichetta della domanda e delle opzioni dalla definizione attuale, nell'ordine + * del wizard. Le risposte a domande che la definizione non ha più restano, con l'id come etichetta. + */ +export function answerRows(answers: ConfiguratorAnswers, definition: ConfiguratorDefinition | undefined): AnswerRow[] { + const rows: AnswerRow[] = [] + const seen = new Set() + const provinces = new Map(definition?.provinces.map((p) => [p.code, p.name]) ?? []) + + for (const step of definition?.steps ?? []) { + for (const q of step.questions) { + const value = answers[q.id] + if (value === undefined) continue + seen.add(q.id) + let text: string + switch (q.type) { + case 'single': + text = q.options.find((o) => o.value === value)?.label ?? String(value) + break + case 'multi': + text = (Array.isArray(value) ? value : [String(value)]).map((v) => q.options.find((o) => o.value === v)?.label ?? v).join(', ') + break + case 'number': + text = `${typeof value === 'number' ? value.toLocaleString('it-IT') : String(value)} ${q.unit}`.trim() + break + case 'province': + text = provinces.has(String(value)) ? `${provinces.get(String(value))} (${String(value)})` : String(value) + break + } + rows.push({ label: q.label, value: text }) + } + } + for (const [id, value] of Object.entries(answers)) { + if (!seen.has(id)) rows.push({ label: id, value: Array.isArray(value) ? value.join(', ') : String(value) }) + } + return rows +} + +/** Stime del risultato in righe (solo quelle calcolate). */ +export function estimateRows(result: ConfiguratorResult): AnswerRow[] { + const rows: AnswerRow[] = [] + if (result.size) rows.push({ label: 'Taglia', value: result.size.label }) + const e = result.estimates + if (e.investmentCents !== null) rows.push({ label: 'Investimento stimato', value: formatEuro(e.investmentCents) }) + if (e.annualSavingsCents !== null) rows.push({ label: 'Risparmio annuo', value: `${formatEuro(e.annualSavingsCents)}/anno` }) + if (e.paybackYears !== null) rows.push({ label: 'Rientro', value: `${e.paybackYears.toLocaleString('it-IT')} anni` }) + return rows +} diff --git a/apps/private/src/app/pages/admin/contact-detail.ts b/apps/private/src/app/pages/admin/contact-detail.ts new file mode 100644 index 0000000..de67f40 --- /dev/null +++ b/apps/private/src/app/pages/admin/contact-detail.ts @@ -0,0 +1,113 @@ +import type { ContactRequestDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { CONTACT_STATUS, optionsOf } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, isNotFound } from './admin-common' +import { StatusSelect } from './status-select' + +/** Messaggio dal form contatti: testo, contatti per rispondere (email/telefono) e stato di gestione. */ +@Component({ + selector: 'app-admin-contact-detail', + imports: [RouterLink, Badge, StatusSelect, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Contatti + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Messaggio non trovato

    + + } @else if (res.error()) { +

    Messaggio

    + + } @else if (res.value(); as c) { +
    +

    {{ c.subject || 'Messaggio senza oggetto' }}

    + +
    + @if (notice(); as n) { +

    {{ n.text }}

    + } +
    +
    +

    Messaggio

    +

    {{ c.message }}

    +
    +
    +

    Mittente

    +
    +
    Nome
    +
    {{ c.name }}
    +
    Email
    +
    {{ c.email }}
    +
    Telefono
    +
    + @if (c.phone) { + {{ c.phone }} + } @else { + — + } +
    +
    Ricevuto
    +
    {{ dateTime(c.createdAt) }}
    + @if (c.userId) { +
    Account
    +
    Apri l'utente
    + } +
    + +
    +
    + } + `, + styles: ` + .status { + display: block; + margin-top: var(--space-5); + } + `, +}) +export default class AdminContactDetail { + private readonly api = inject(ApiService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/contact-requests/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + protected readonly options = optionsOf(CONTACT_STATUS) + protected readonly labels = CONTACT_STATUS + protected readonly dateTime = formatDateTime + + constructor() { + const setTitle = adminTitle() + effect(() => setTitle(this.res.value() ? `Messaggio di ${this.res.value()?.name}` : 'Messaggio')) + } + + protected replyLink(c: ContactRequestDto): string { + return `mailto:${c.email}?subject=${encodeURIComponent(`Re: ${c.subject || 'la tua richiesta a ITI TECHNOLOGY'}`)}` + } + + protected async setStatus(c: ContactRequestDto, status: string): Promise { + this.busy.set(true) + this.notice.set(null) + try { + this.res.set(await firstValueFrom(this.api.patch(`/admin/contact-requests/${c.id}`, { status }))) + this.notice.set({ ok: true, text: 'Stato aggiornato.' }) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/contacts.ts b/apps/private/src/app/pages/admin/contacts.ts new file mode 100644 index 0000000..9d6709f --- /dev/null +++ b/apps/private/src/app/pages/admin/contacts.ts @@ -0,0 +1,88 @@ +import type { ContactRequestDto, ContactRequestStatus, 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 { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { CONTACT_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common' + +const STATUSES = Object.keys(CONTACT_STATUS) as ContactRequestStatus[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: CONTACT_STATUS[s].label }))] + +/** Messaggi dal form contatti del sito. */ +@Component({ + selector: 'app-admin-contacts', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Contatti

    +
    + + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { + + + } @else { + + } + } + `, + styles: ` + .excerpt { + color: var(--color-ink-muted); + overflow-wrap: anywhere; + } + `, +}) +export default class AdminContacts { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly q = input() + + protected readonly filters = FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as ContactRequestStatus) : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/contact-requests', { query: { ...pageParams(params.page), status: params.status, q: params.q } }), + }) + + protected readonly labels = CONTACT_STATUS + protected readonly dateTime = formatDateTime + protected excerpt(text: string): string { + const flat = text.replace(/\s+/g, ' ').trim() + return flat.length > 140 ? `${flat.slice(0, 139)}…` : flat + } + + constructor() { + adminTitle()('Contatti') + } +} diff --git a/apps/private/src/app/pages/admin/documents.ts b/apps/private/src/app/pages/admin/documents.ts new file mode 100644 index 0000000..ecc59cb --- /dev/null +++ b/apps/private/src/app/pages/admin/documents.ts @@ -0,0 +1,344 @@ +import type { DocumentDto, DocumentVisibility, Paginated } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, type ElementRef, inject, input, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { DownloadService, fileName } from '../../core/download.service' +import { formatBytes, formatDate } from '../../core/format' +import { optionsOf, VISIBILITY_LABELS } from '../../core/labels' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle } from './admin-common' +import { multipart, uploadErrorMessage } from './upload' + +const PAGE_SIZE = 50 +const VISIBILITIES = Object.keys(VISIBILITY_LABELS) as DocumentVisibility[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...VISIBILITIES.map((v) => ({ value: v, label: VISIBILITY_LABELS[v] }))] +/** Estensioni ammesse dall'API (verifica vera lato server sui magic bytes). */ +export const DOCUMENT_ACCEPT = '.pdf,.zip,.dwg,.dxf,.jpg,.jpeg,.png,.webp,.gif' + +const HANDLE = /^[a-z0-9-]+$/ + +function metadataGroup() { + return new FormGroup({ + title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(200)] }), + category: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(100)] }), + visibility: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + productRef: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200), Validators.pattern(HANDLE)] }), + }) +} +type MetadataForm = ReturnType + +/** Metadati per l'API: campi facoltativi vuoti → null (nel PATCH cancellano il valore). */ +export function documentMetadata(v: ReturnType) { + return { + title: v.title.trim(), + category: v.category.trim() || null, + visibility: v.visibility, + productRef: v.productRef.trim() || null, + } +} + +/** + * Documenti: caricamento con visibilità per ruolo, modifica dei metadati, eliminazione (riga e file). + * I PDF dei preventivi sono documenti "Solo staff" in categoria `preventivi`: si gestiscono dal preventivo. + */ +@Component({ + selector: 'app-admin-documents', + imports: [ReactiveFormsModule, FormField, FilterPills, SearchBox, Pager, ConfirmDialog, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Documenti

    + +
    + Carica un documento +
    +
    +
    + +

    PDF, ZIP, DWG, DXF o immagini; il tipo viene verificato sul contenuto.

    + + @if (fileError(); as e) { +

    {{ e }}

    + } +
    + +
    + + + @if (submitted() && upload.controls.visibility.invalid) { +

    Scegli chi può vedere il documento

    + } +
    + + +
    + @if (uploadError(); as e) { + + } + +
    +
    + + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    + + +
    + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + + + @for (d of data.items; track d.id) { + + + + + + + + + + @if (editing() === d.id) { + + + + } + } + +
    TitoloCategoriaVisibilitàProdottoDimensioneCaricatoAzioni
    {{ d.title }}{{ d.category ?? '—' }}{{ visibilityLabels[d.visibility] }}{{ d.productRef ?? '—' }}{{ bytes(d.sizeBytes) }}{{ date(d.createdAt) }} + + + +
    +
    +
    + +
    + + +
    + + +
    +
    + + +
    +
    +
    +
    + + } @else { + + } + } + + + `, + styles: ` + .upload summary { + min-height: 44px; + cursor: pointer; + font-weight: var(--font-weight-semibold); + } + .upload form { + margin-top: var(--space-4); + } + .form-grid { + margin-bottom: var(--space-4); + } + .row-actions { + white-space: nowrap; + + .link-button + .link-button { + margin-left: var(--space-3); + } + } + .danger { + color: var(--color-danger); + } + .edit-row td { + background: var(--color-surface-alt); + } + `, +}) +export default class AdminDocuments { + private readonly api = inject(ApiService) + private readonly downloads = inject(DownloadService) + readonly pagina = input() + readonly visibilita = input() + readonly q = input() + + protected readonly filters = FILTERS + protected readonly pageSize = PAGE_SIZE + protected readonly accept = DOCUMENT_ACCEPT + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly visibility = computed(() => + (VISIBILITIES as string[]).includes(this.visibilita() ?? '') ? (this.visibilita() as DocumentVisibility) : null, + ) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), visibility: this.visibility(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/documents', { + query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, visibility: params.visibility, q: params.q }, + }), + }) + + protected readonly upload = metadataGroup() + protected readonly edit = metadataGroup() + private file: File | null = null + protected readonly uploadOpen = signal(false) + protected readonly submitted = signal(false) + protected readonly fileError = signal(null) + protected readonly uploadError = signal(null) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + protected readonly editing = signal(null) + protected readonly deleting = signal(null) + private readonly dialog = viewChild(ConfirmDialog) + private readonly fileInput = viewChild>('fileInput') + + protected readonly visibilityOptions = optionsOf(VISIBILITY_LABELS) + protected readonly visibilityLabels = VISIBILITY_LABELS + protected readonly bytes = formatBytes + protected readonly date = formatDate + + constructor() { + adminTitle()('Documenti') + } + + protected pick(event: Event): void { + this.file = (event.target as HTMLInputElement).files?.[0] ?? null + this.fileError.set(null) + // titolo proposto dal nome del file, se non è già stato scritto + if (this.file && !this.upload.controls.title.value) { + this.upload.controls.title.setValue(this.file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim()) + } + } + + protected async create(): Promise { + this.submitted.set(true) + this.uploadError.set(null) + this.notice.set(null) + if (!this.file) this.fileError.set('Seleziona un file') + if (this.upload.invalid || !this.file || this.busy()) return + this.busy.set(true) + try { + const meta = documentMetadata(this.upload.getRawValue()) + const doc = await firstValueFrom(this.api.post('/admin/documents', multipart(this.file, meta))) + this.upload.reset() + this.file = null + const field = this.fileInput()?.nativeElement + if (field) field.value = '' + this.submitted.set(false) + this.uploadOpen.set(false) + this.notice.set({ ok: true, text: `Documento «${doc.title}» caricato (${VISIBILITY_LABELS[doc.visibility].toLowerCase()}).` }) + this.res.reload() + } catch (err) { + const e = toApiError(err) + if (!applyServerErrors(this.upload, e)) this.uploadError.set(uploadErrorMessage(e)) + } finally { + this.busy.set(false) + } + } + + protected startEdit(d: DocumentDto): void { + this.notice.set(null) + this.edit.setValue({ title: d.title, category: d.category ?? '', visibility: d.visibility, productRef: d.productRef ?? '' }) + this.editing.set(this.editing() === d.id ? null : d.id) + } + + protected async saveEdit(d: DocumentDto): Promise { + if (this.edit.invalid || this.busy()) return + this.busy.set(true) + try { + const updated = await firstValueFrom(this.api.patch(`/admin/documents/${d.id}`, documentMetadata(this.edit.getRawValue()))) + this.res.update((page) => page && { ...page, items: page.items.map((x) => (x.id === updated.id ? updated : x)) }) + this.editing.set(null) + this.notice.set({ ok: true, text: 'Documento aggiornato.' }) + } catch (err) { + const e = toApiError(err) + if (!applyServerErrors(this.edit, e)) this.notice.set({ ok: false, text: e.message }) + } finally { + this.busy.set(false) + } + } + + protected askDelete(d: DocumentDto): void { + this.notice.set(null) + this.deleting.set(d) + this.dialog()?.open() + } + + protected async remove(): Promise { + const d = this.deleting() + if (!d || this.busy()) return + this.busy.set(true) + try { + await firstValueFrom(this.api.delete(`/admin/documents/${d.id}`)) + this.notice.set({ ok: true, text: `Documento «${d.title}» eliminato.` }) + this.res.reload() + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + this.deleting.set(null) + this.dialog()?.close() + } + } + + protected async download(d: DocumentDto): Promise { + try { + await this.downloads.download(`/documents/${d.id}/download`, fileName(d.title, d.mimeType)) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } + } +} diff --git a/apps/private/src/app/pages/admin/installer-detail.ts b/apps/private/src/app/pages/admin/installer-detail.ts new file mode 100644 index 0000000..6920542 --- /dev/null +++ b/apps/private/src/app/pages/admin/installer-detail.ts @@ -0,0 +1,239 @@ +import type { InstallerAdminDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { formatDate, formatDateTime } from '../../core/format' +import { INSTALLER_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { FormField } from '../../shared/form-field' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, fullName, isNotFound } from './admin-common' + +type Decision = 'approve' | 'reject' +type DecisionResult = InstallerAdminDto & { medusaSynced: boolean } + +/** Link di OpenStreetMap centrato su un punto (nessuna richiesta a terzi finché non lo si apre). */ +export const osmLink = (p: { lat: number; lng: number }) => + `https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lng}#map=12/${p.lat}/${p.lng}` + +/** + * Profilo installatore lato staff: dati aziendali, zona, certificazioni, note interne, approvazione o rifiuto + * (con email all'installatore e allineamento del listino riservato in Medusa). + */ +@Component({ + selector: 'app-admin-installer-detail', + imports: [ReactiveFormsModule, RouterLink, Badge, ConfirmDialog, FormField, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Installatori + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Installatore non trovato

    + + } @else if (res.error()) { +

    Installatore

    + + } @else if (res.value(); as i) { +
    +

    {{ i.businessName }}

    + +
    + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    +
    +
    +

    Dati aziendali

    +
    +
    Referente
    +
    {{ name(i) }} · {{ i.email }}
    +
    P.IVA
    +
    {{ i.vatNumber ?? '—' }}
    +
    Telefono
    +
    {{ i.phone ?? '—' }}
    +
    Indirizzo
    +
    {{ i.address ?? '—' }}
    +
    Zona
    +
    + @if (i.location; as loc) { + raggio {{ i.serviceRadiusKm }} km da {{ loc.lat }}, {{ loc.lng }} · + vedi sulla mappa + } @else { + sede non indicata (non compare nelle ricerche per zona) + } +
    +
    Certificazioni
    +
    {{ i.certifications.length ? i.certifications.join(', ') : '—' }}
    +
    Registrato
    +
    {{ dateTime(i.createdAt) }}
    + @if (i.approvedAt) { +
    Approvato il
    +
    {{ date(i.approvedAt) }}
    + } + @if (i.disabled) { +
    Account
    +
    disabilitato
    + } +
    +
    + +
    +

    Note interne

    + + + +
    + +
    +

    Decisione

    + @switch (i.status) { + @case ('pending') { +

    Verifica i dati e la documentazione, poi approva: l'installatore vedrà subito listino riservato e documentazione tecnica.

    + } + @case ('approved') { +

    Approvato: vede il listino riservato e i documenti per installatori. Rifiutandolo li perde subito.

    + } + @case ('rejected') { +

    Rifiutato. Puoi approvarlo se nel frattempo ha completato i requisiti.

    + } + } +
    + @if (i.status !== 'approved') { + + } + @if (i.status !== 'rejected') { + + } +
    +

    + Listino non allineato? + +

    +
    +
    + + + @if (decision() === 'reject') { + + } + + } + `, + styles: ` + .sync { + margin: var(--space-4) 0 0; + } + `, +}) +export default class AdminInstallerDetail { + private readonly api = inject(ApiService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/installers/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + + protected readonly notes = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(5000)] }) + protected readonly reason = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(1000)] }) + protected readonly decision = signal(null) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + private readonly dialog = viewChild(ConfirmDialog) + + protected readonly labels = INSTALLER_STATUS + protected readonly date = formatDate + protected readonly dateTime = formatDateTime + protected readonly name = fullName + protected readonly osm = osmLink + + constructor() { + const setTitle = adminTitle() + effect(() => { + const i = this.res.value() + setTitle(i ? i.businessName : 'Installatore') + if (i) this.notes.setValue(i.notes ?? '') + }) + } + + protected ask(decision: Decision): void { + this.notice.set(null) + this.reason.reset() + this.decision.set(decision) + this.dialog()?.open() + } + + protected async decide(i: InstallerAdminDto): Promise { + const decision = this.decision() + if (!decision || this.busy()) return + this.busy.set(true) + try { + const reason = this.reason.value.trim() + const result = await firstValueFrom( + this.api.post(`/admin/installers/${i.userId}/${decision}`, decision === 'reject' && reason ? { reason } : {}), + ) + const { medusaSynced, ...profile } = result + this.res.set(profile) + const text = decision === 'approve' ? 'Installatore approvato: email inviata.' : 'Installatore rifiutato: email inviata.' + this.notice.set( + medusaSynced + ? { ok: true, text } + : { ok: false, text: `${text} Lo shop non ha risposto: il listino si allineerà al suo prossimo accesso, oppure usa "Riallinea con lo shop".` }, + ) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + this.decision.set(null) + this.dialog()?.close() + } + } + + protected async saveNotes(i: InstallerAdminDto): Promise { + this.busy.set(true) + this.notice.set(null) + try { + this.res.set(await firstValueFrom(this.api.patch(`/admin/installers/${i.userId}`, { notes: this.notes.value }))) + this.notice.set({ ok: true, text: 'Note salvate.' }) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + } + } + + protected async sync(i: InstallerAdminDto): Promise { + this.busy.set(true) + this.notice.set(null) + try { + const { member } = await firstValueFrom(this.api.post<{ member: boolean }>(`/admin/installers/${i.userId}/sync`, null)) + this.notice.set({ ok: true, text: member ? 'Allineato: listino riservato attivo.' : 'Allineato: nessun listino riservato.' }) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/installers.ts b/apps/private/src/app/pages/admin/installers.ts new file mode 100644 index 0000000..6d91510 --- /dev/null +++ b/apps/private/src/app/pages/admin/installers.ts @@ -0,0 +1,102 @@ +import type { InstallerAdminDto, InstallerStatus, 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 { ApiService } from '../../core/api.service' +import { formatDate } from '../../core/format' +import { INSTALLER_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common' + +const STATUSES = Object.keys(INSTALLER_STATUS) as InstallerStatus[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: INSTALLER_STATUS[s].label }))] + +/** Installatori per stato del profilo, ricerca per ragione sociale o email. */ +@Component({ + selector: 'app-admin-installers', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Installatori

    +
    + + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + @for (i of data.items; track i.userId) { + + + + + + + + } + +
    Ragione socialeReferenteZonaRegistratoStato
    + {{ i.businessName }} + @if (i.vatNumber) { +
    P.IVA {{ i.vatNumber }} + } +
    + {{ name(i) }}
    + {{ i.email }} +
    {{ i.location ? 'raggio ' + i.serviceRadiusKm + ' km' : 'sede non indicata' }}{{ date(i.createdAt) }} + + @if (i.disabled) { +
    account disabilitato + } +
    +
    + + } @else { + + } + } + `, +}) +export default class AdminInstallers { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly q = input() + + protected readonly filters = FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as InstallerStatus) : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/installers', { query: { ...pageParams(params.page), status: params.status, q: params.q } }), + }) + + protected readonly labels = INSTALLER_STATUS + protected readonly date = formatDate + protected readonly name = fullName + + constructor() { + adminTitle()('Installatori') + } +} diff --git a/apps/private/src/app/pages/admin/markdown-edit.ts b/apps/private/src/app/pages/admin/markdown-edit.ts new file mode 100644 index 0000000..ad5558e --- /dev/null +++ b/apps/private/src/app/pages/admin/markdown-edit.ts @@ -0,0 +1,92 @@ +/** + * Modifiche al testo markdown dell'editor del blog (funzioni pure: testo + selezione → testo + nuova selezione). + * La selezione restituita evidenzia la parte da completare (es. l'URL di un link), così si scrive subito sopra. + */ + +export interface TextEdit { + text: string + /** Inizio e fine della selezione dopo la modifica. */ + start: number + end: number +} + +export type MarkdownAction = 'bold' | 'italic' | 'h2' | 'h3' | 'link' | 'list' | 'quote' + +const LINK_PLACEHOLDER = 'https://' + +/** Avvolge la selezione con `mark` (es. `**`); senza selezione inserisce `mark` + `placeholder` + `mark` selezionando il segnaposto. */ +function wrap(text: string, start: number, end: number, mark: string, placeholder: string): TextEdit { + const selected = text.slice(start, end) + const inner = selected || placeholder + const next = `${text.slice(0, start)}${mark}${inner}${mark}${text.slice(end)}` + return { text: next, start: start + mark.length, end: start + mark.length + inner.length } +} + +/** Applica `prefix` all'inizio di ogni riga toccata dalla selezione (titoli, elenchi, citazioni); i titoli sostituiscono quelli esistenti. */ +function prefixLines(text: string, start: number, end: number, prefix: string, heading: boolean): TextEdit { + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + const lineEndIdx = text.indexOf('\n', end) + const lineEnd = lineEndIdx === -1 ? text.length : lineEndIdx + const lines = text.slice(lineStart, lineEnd).split('\n') + const changed = lines.map((line) => { + const base = heading ? line.replace(/^#{1,6}\s+/, '') : line + return `${prefix}${base}` + }) + const block = changed.join('\n') + return { text: `${text.slice(0, lineStart)}${block}${text.slice(lineEnd)}`, start: lineStart + prefix.length, end: lineStart + block.length } +} + +export function applyMarkdown(text: string, start: number, end: number, action: MarkdownAction): TextEdit { + switch (action) { + case 'bold': + return wrap(text, start, end, '**', 'testo in grassetto') + case 'italic': + return wrap(text, start, end, '_', 'testo in corsivo') + case 'h2': + return prefixLines(text, start, end, '## ', true) + case 'h3': + return prefixLines(text, start, end, '### ', true) + case 'list': + return prefixLines(text, start, end, '- ', false) + case 'quote': + return prefixLines(text, start, end, '> ', false) + case 'link': { + const label = text.slice(start, end) || 'testo del link' + const snippet = `[${label}](${LINK_PLACEHOLDER})` + const urlStart = start + label.length + 3 + return { text: `${text.slice(0, start)}${snippet}${text.slice(end)}`, start: urlStart, end: urlStart + LINK_PLACEHOLDER.length } + } + } +} + +/** Testo alternativo proposto per un'immagine caricata (selezionato: va sostituito con una descrizione vera). */ +export const IMAGE_ALT_PLACEHOLDER = "Descrizione dell'immagine" + +/** + * Inserisce un'immagine `![alt](url)` al posto della selezione, su una riga a sé (un'immagine in mezzo a una + * frase resterebbe dentro il paragrafo). Seleziona il testo alternativo. + */ +export function insertImage(text: string, start: number, end: number, url: string): TextEdit { + const before = text.slice(0, start) + const after = text.slice(end) + const lead = before === '' || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n' + const trail = after === '' || after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n' + const altStart = before.length + lead.length + 2 + const snippet = `${lead}![${IMAGE_ALT_PLACEHOLDER}](${url})${trail}` + return { text: `${before}${snippet}${after}`, start: altStart, end: altStart + IMAGE_ALT_PLACEHOLDER.length } +} + +/** ISO → valore di `` nell'ora locale del browser ("2026-10-05T14:30"). */ +export function toLocalInput(iso: string | null): string { + if (!iso) return '' + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +/** Valore di `datetime-local` (ora locale) → ISO, o null se vuoto/non valido. */ +export function fromLocalInput(value: string): string | null { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d.toISOString() +} diff --git a/apps/private/src/app/pages/admin/quote-detail.ts b/apps/private/src/app/pages/admin/quote-detail.ts new file mode 100644 index 0000000..4168056 --- /dev/null +++ b/apps/private/src/app/pages/admin/quote-detail.ts @@ -0,0 +1,222 @@ +import type { QuoteAdminDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { DownloadService } from '../../core/download.service' +import { formatDateTime, formatDay, formatEuro } from '../../core/format' +import { ADMIN_QUOTE_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, fullName, isNotFound } from './admin-common' +import { multipart, uploadErrorMessage } from './upload' +import { QuoteForm } from './quote-form' + +/** + * Preventivo: dati (modificabili solo in bozza), PDF allegato (caricamento/sostituzione, download) e invio al + * cliente con email. Il cliente lo vede nell'area clienti solo dopo l'invio. + */ +@Component({ + selector: 'app-admin-quote-detail', + imports: [RouterLink, Badge, ConfirmDialog, QuoteForm, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Preventivi + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Preventivo non trovato

    + + } @else if (res.error()) { +

    Preventivo

    + + } @else if (res.value(); as q) { +
    +

    Preventivo {{ q.number }}

    + +
    + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    +
    +

    {{ q.title }}

    +
    +
    Cliente
    +
    + {{ q.customer.companyName ?? name(q.customer) }}
    + {{ q.customer.email }} +
    + @if (q.quoteRequestId) { +
    Richiesta
    +
    Apri la richiesta
    + } +
    Importo
    +
    {{ euro(q.amountCents) }} IVA inclusa
    +
    Validità
    +
    {{ q.validUntil ? 'fino al ' + day(q.validUntil) : 'senza scadenza' }}
    +
    Creato
    +
    {{ dateTime(q.createdAt) }}
    +
    Inviato
    +
    {{ q.sentAt ? dateTime(q.sentAt) : 'non ancora' }}
    +
    + + @if (q.status === 'draft') { +

    Modifica

    + + } +
    + +
    +
    +

    PDF

    + @if (q.pdfDocumentId) { +

    PDF allegato.

    + + } @else { +

    Nessun PDF allegato: il cliente vedrà solo titolo, importo e validità.

    + } + @if (open()) { +
    + + + @if (uploading()) { +

    Caricamento…

    + } +
    + } +
    + + @if (open()) { +
    +

    {{ q.status === 'sent' ? 'Invia di nuovo' : 'Invio al cliente' }}

    +

    + Il cliente riceve un'email con il link al preventivo e può accettarlo o rifiutarlo dall'area clienti. + @if (q.status === 'draft') { + Dopo l'invio i dati non sono più modificabili. + } +

    + +
    + } +
    +
    + + + } + `, + styles: ` + h3 { + margin-top: var(--space-5); + font-size: var(--font-size-lg); + } + .upload { + margin-top: var(--space-4); + } + `, +}) +export default class AdminQuoteDetail { + private readonly api = inject(ApiService) + private readonly downloads = inject(DownloadService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/quotes/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + /** Bozza o inviato senza risposta: PDF e invio ancora possibili. */ + protected readonly open = computed(() => { + const s = this.res.value()?.status + return s === 'draft' || s === 'sent' + }) + + protected readonly notice = signal<{ ok: boolean; text: string } | null>( + (inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created + ? { ok: true, text: 'Bozza creata: allega il PDF e inviala al cliente.' } + : null, + ) + protected readonly uploading = signal(false) + protected readonly sending = signal(false) + protected readonly downloading = signal(false) + protected readonly dialog = viewChild(ConfirmDialog) + + protected readonly labels = ADMIN_QUOTE_STATUS + protected readonly euro = formatEuro + protected readonly day = formatDay + protected readonly dateTime = formatDateTime + protected readonly name = fullName + + constructor() { + const setTitle = adminTitle() + effect(() => { + const q = this.res.value() + setTitle(q ? `Preventivo ${q.number}` : 'Preventivo') + }) + } + + protected updated(quote: QuoteAdminDto, text: string): void { + this.res.set(quote) + this.notice.set({ ok: true, text }) + } + + protected async uploadPdf(quote: QuoteAdminDto, event: Event): Promise { + const field = event.target as HTMLInputElement + const file = field.files?.[0] + if (!file) return + const body = multipart(file) + this.uploading.set(true) + this.notice.set(null) + try { + this.updated(await firstValueFrom(this.api.post(`/admin/quotes/${quote.id}/pdf`, body)), 'PDF caricato.') + } catch (err) { + this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) }) + } finally { + field.value = '' + this.uploading.set(false) + } + } + + protected async send(quote: QuoteAdminDto): Promise { + if (this.sending()) return + this.sending.set(true) + try { + this.updated(await firstValueFrom(this.api.post(`/admin/quotes/${quote.id}/send`, null)), `Preventivo inviato a ${quote.customer.email}.`) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + this.res.reload() + } finally { + this.sending.set(false) + this.dialog()?.close() + } + } + + protected async downloadPdf(quote: QuoteAdminDto): Promise { + if (!quote.pdfDocumentId) return + this.downloading.set(true) + try { + await this.downloads.download(`/documents/${quote.pdfDocumentId}/download`, `Preventivo ${quote.number}.pdf`) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.downloading.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/quote-form.ts b/apps/private/src/app/pages/admin/quote-form.ts new file mode 100644 index 0000000..973d993 --- /dev/null +++ b/apps/private/src/app/pages/admin/quote-form.ts @@ -0,0 +1,124 @@ +import type { QuoteAdminDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, effect, inject, input, output, signal } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators, type AbstractControl, type ValidationErrors } from '@angular/forms' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { centsToInput, parseEuroToCents } from './admin-common' + +/** Importo in euro valido (formato italiano). */ +export function euroValidator(control: AbstractControl): ValidationErrors | null { + if (!control.value?.trim()) return null + return parseEuroToCents(control.value) === null ? { euro: true } : null +} + +/** Corpo comune di creazione e modifica: titolo, importo in centesimi, validità (null = senza scadenza). */ +export function quoteBody(value: { title: string; amount: string; validUntil: string }) { + return { + title: value.title.trim(), + amountCents: parseEuroToCents(value.amount) ?? 0, + validUntil: value.validUntil || null, + } +} + +/** + * Dati di un preventivo: crea una bozza (`POST /admin/quotes`, da una richiesta o per un utente) oppure + * modifica quella esistente (`PATCH /admin/quotes/:id`, solo in bozza). Emette il preventivo salvato. + */ +@Component({ + selector: 'app-quote-form', + imports: [ReactiveFormsModule, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +
    + + +
    + + + @if (dateError(); as e) { +

    {{ e }}

    + } +
    +
    + @if (error(); as e) { + + } + +
    + `, + styles: ` + .form-grid { + margin-bottom: var(--space-4); + } + `, +}) +export class QuoteForm { + private readonly api = inject(ApiService) + /** Preventivo da modificare; se assente si crea una bozza per `quoteRequestId` o `userId`. */ + readonly quote = input(null) + readonly quoteRequestId = input(null) + readonly userId = input(null) + readonly submitLabel = input('Crea la bozza') + readonly saved = output() + + private static seq = 0 + protected readonly dateId = `quote-valid-${++QuoteForm.seq}` + protected readonly form = new FormGroup({ + title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3), Validators.maxLength(200)] }), + amount: new FormControl('', { nonNullable: true, validators: [Validators.required, euroValidator] }), + validUntil: new FormControl('', { nonNullable: true }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + protected readonly dateError = signal(null) + + constructor() { + effect(() => { + const q = this.quote() + if (q) this.form.setValue({ title: q.title, amount: centsToInput(q.amountCents), validUntil: q.validUntil ?? '' }) + }) + } + + protected async save(): Promise { + this.submitted.set(true) + this.error.set(null) + this.dateError.set(null) + if (this.form.invalid || this.busy()) return + const body = quoteBody(this.form.getRawValue()) + const existing = this.quote() + this.busy.set(true) + try { + const saved = existing + ? await firstValueFrom(this.api.patch(`/admin/quotes/${existing.id}`, body)) + : await firstValueFrom( + this.api.post('/admin/quotes', { + ...body, + ...(this.quoteRequestId() ? { quoteRequestId: this.quoteRequestId() } : {}), + ...(this.userId() ? { userId: this.userId() } : {}), + }), + ) + this.submitted.set(false) + if (!existing) this.form.reset() + this.saved.emit(saved) + } catch (err) { + const e = toApiError(err) + const onDate = e.details.find((d) => d.path === 'body.validUntil') + if (onDate) this.dateError.set(onDate.message) + if (!applyServerErrors(this.form, e, { amountCents: 'amount' }) && !onDate) this.error.set(e.message) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/quote-request-detail.ts b/apps/private/src/app/pages/admin/quote-request-detail.ts new file mode 100644 index 0000000..153385d --- /dev/null +++ b/apps/private/src/app/pages/admin/quote-request-detail.ts @@ -0,0 +1,327 @@ +import type { + ConfiguratorDefinition, + NearbyInstallerDto, + QuoteAdminDto, + QuoteRequestAdminDto, +} from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiError, ApiService, toApiError } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatDateTime, formatEuro } from '../../core/format' +import { ADMIN_QUOTE_STATUS, optionsOf, QUOTE_REQUEST_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Empty, Loading, LoadError, loadErrorMessage } from '../../shared/state' +import { adminTitle, isNotFound } from './admin-common' +import { answerRows, estimateRows, readConfiguratorInput, readConfiguratorResult } from './configurator-summary' +import { QuoteForm } from './quote-form' +import { StatusSelect } from './status-select' + +type RequestDetail = QuoteRequestAdminDto & { quotes: QuoteAdminDto[] } +type NearbyResult = { province: string; point: { lat: number; lng: number }; installers: NearbyInstallerDto[] } + +/** + * Dettaglio di una richiesta di preventivo: contatti, risposte del configuratore (etichette dalla definizione + * attuale), risultato calcolato, installatori che coprono la provincia, preventivi collegati e creazione di + * una nuova bozza (si completa con PDF e invio nella pagina del preventivo). + */ +@Component({ + selector: 'app-admin-quote-request-detail', + imports: [RouterLink, Badge, QuoteForm, StatusSelect, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Richieste di preventivo + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Richiesta non trovata

    + + } @else if (res.error()) { +

    Richiesta di preventivo

    + + } @else if (res.value(); as r) { +
    +

    {{ r.name }}

    + +
    + +
    +
    +
    +

    Contatti

    +
    +
    Email
    +
    {{ r.email }}
    +
    Telefono
    +
    + @if (r.phone) { + {{ r.phone }} + } @else { + — + } +
    +
    Provincia
    +
    {{ r.province ?? '—' }}
    +
    Ricevuta
    +
    {{ dateTime(r.createdAt) }}
    +
    Account
    +
    + @if (r.userId) { + Apri l'utente + } @else { + nessuno (richiesta anonima) + } +
    +
    + @if (input().message) { +

    Messaggio

    +

    {{ input().message }}

    + } +
    + + @if (result(); as out) { +
    +

    Risultato del configuratore

    +

    {{ out.solution.title }}

    +

    {{ out.solution.description }}

    + @if (estimates().length) { +
    + @for (row of estimates(); track row.label) { +
    {{ row.label }}
    +
    {{ row.value }}
    + } +
    + } + @if (out.warnings.length) { +
      + @for (w of out.warnings; track w) { +
    • {{ w }}
    • + } +
    + } + @if (out.products.length) { +

    Prodotti suggeriti

    +
      + @for (p of out.products; track p.handle) { +
    • + {{ p.handle }} × {{ p.quantity }} + @if (p.note) { + — {{ p.note }} + } +
    • + } +
    + } +
    + Come è stato calcolato +
    + @for (d of out.details; track d.label) { +
    {{ d.label }}
    +
    {{ d.value }}
    + } +
    +

    Regole versione {{ out.rulesVersion }}

    +
    +
    + } + +
    +

    Risposte

    + @if (answers().length) { +
    + @for (a of answers(); track a.label) { +
    {{ a.label }}
    +
    {{ a.value }}
    + } +
    + @if (definition.error()) { +

    Etichette non disponibili: sono mostrati gli identificativi delle domande.

    + } @else if (versionChanged()) { +

    Il configuratore è cambiato dopo l'invio (versione {{ input().definitionVersion }}): alcune etichette potrebbero non corrispondere.

    + } + } @else { +

    Nessuna risposta registrata.

    + } +
    +
    + +
    +
    +

    Stato

    + + @if (statusMessage(); as m) { +

    {{ m }}

    + } +
    + +
    +

    Installatori in zona

    + @if (nearby.isLoading()) { + + } @else if (nearbyError(); as e) { +

    {{ e }}

    + } @else if (nearby.value(); as n) { + @if (n.installers.length) { +

    Approvati il cui raggio copre il capoluogo di {{ n.province }}.

    +
      + @for (i of n.installers; track i.userId) { +
    • + {{ i.businessName }} + · {{ km(i.distanceKm) }} km (raggio {{ i.serviceRadiusKm }})
      + {{ i.phone ?? i.email }} +
    • + } +
    + } @else { +

    Nessun installatore approvato copre la provincia di {{ n.province }}.

    + } + } +
    +
    +
    + +
    +

    Preventivi

    + @if (r.quotes.length) { + + } @else { +

    Nessun preventivo per questa richiesta.

    + } + +
    +

    Nuovo preventivo

    +

    + Si crea una bozza intestata all'account del richiedente (se non c'è, quello con la stessa email). Poi si + allega il PDF e si invia. +

    + +
    +
    + } + `, + styles: ` + h3 { + margin-top: var(--space-4); + font-size: var(--font-size-lg); + } + .warnings { + padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6); + border-radius: var(--radius-md); + background: var(--color-accent-soft); + } + details { + margin-top: var(--space-4); + } + summary { + min-height: 44px; + cursor: pointer; + font-weight: var(--font-weight-semibold); + } + .plain { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; + } + .new-quote { + margin-top: var(--space-5); + + h3 { + margin-top: 0; + } + } + `, +}) +export default class AdminQuoteRequestDetail { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/quote-requests/${encodeURIComponent(params)}`), + }) + protected readonly definition = rxResource({ + stream: () => this.api.get('/configurator/definition', { anonymous: true }), + }) + protected readonly nearby = rxResource({ + params: () => (this.res.hasValue() ? this.id() : undefined), + stream: ({ params }) => this.api.get(`/admin/quote-requests/${encodeURIComponent(params)}/installers`), + }) + + protected readonly notFound = computed(() => isNotFound(this.res.error())) + protected readonly input = computed(() => readConfiguratorInput(this.res.value()?.configuratorInput)) + protected readonly result = computed(() => readConfiguratorResult(this.res.value()?.configuratorOutput)) + protected readonly estimates = computed(() => { + const r = this.result() + return r ? estimateRows(r) : [] + }) + protected readonly answers = computed(() => + answerRows(this.input().answers ?? {}, this.definition.hasValue() ? this.definition.value() : undefined), + ) + protected readonly versionChanged = computed(() => { + const v = this.input().definitionVersion + return this.definition.hasValue() && v !== undefined && v !== this.definition.value().version + }) + protected readonly nearbyError = computed(() => { + const err = this.nearby.error() + if (!err) return null + return err instanceof ApiError && err.code === 'PROVINCE_MISSING' + ? 'La richiesta non indica una provincia: impossibile cercare installatori in zona.' + : loadErrorMessage(err) + }) + + protected readonly statusOptions = optionsOf(QUOTE_REQUEST_STATUS) + protected readonly busy = signal(false) + protected readonly statusMessage = signal(null) + + protected readonly labels = QUOTE_REQUEST_STATUS + protected readonly quoteLabels = ADMIN_QUOTE_STATUS + protected readonly dateTime = formatDateTime + protected readonly euro = formatEuro + protected readonly km = (n: number) => n.toLocaleString('it-IT') + private readonly siteUrl = inject(APP_CONFIG).siteUrl + protected readonly siteShop = (handle: string) => `${this.siteUrl}/shop/${encodeURIComponent(handle)}` + + constructor() { + const setTitle = adminTitle() + effect(() => { + const r = this.res.value() + setTitle(r ? `Richiesta di ${r.name}` : 'Richiesta di preventivo') + }) + } + + protected async saveStatus(r: RequestDetail, status: string): Promise { + this.busy.set(true) + this.statusMessage.set(null) + try { + const updated = await firstValueFrom(this.api.patch(`/admin/quote-requests/${r.id}`, { status })) + this.res.set({ ...r, ...updated }) + this.statusMessage.set('Stato aggiornato.') + } catch (err) { + this.statusMessage.set(toApiError(err).message) + } finally { + this.busy.set(false) + } + } + + protected created(quote: QuoteAdminDto): void { + void this.router.navigate(['/admin/preventivi', quote.id], { state: { created: true } }) + } +} diff --git a/apps/private/src/app/pages/admin/quote-requests.ts b/apps/private/src/app/pages/admin/quote-requests.ts new file mode 100644 index 0000000..a9e2716 --- /dev/null +++ b/apps/private/src/app/pages/admin/quote-requests.ts @@ -0,0 +1,97 @@ +import type { Paginated, QuoteRequestAdminDto, QuoteRequestStatus } 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 { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { QUOTE_REQUEST_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common' +import { readConfiguratorResult } from './configurator-summary' + +const STATUSES = Object.keys(QUOTE_REQUEST_STATUS) as QuoteRequestStatus[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: QUOTE_REQUEST_STATUS[s].label }))] + +/** Richieste di preventivo (dal configuratore): filtro per stato, ricerca per nome o email. */ +@Component({ + selector: 'app-admin-quote-requests', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Richieste di preventivo

    +
    + + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + @for (r of data.items; track r.id) { + + + + + + + + } + +
    RicevutaRichiedenteSoluzioneProvinciaStato
    {{ dateTime(r.createdAt) }} + {{ r.name }}
    + {{ r.email }} +
    {{ solution(r) }}{{ r.province ?? '—' }}
    +
    + + } @else { + + } + } + `, +}) +export default class AdminQuoteRequests { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly q = input() + + protected readonly filters = FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => (STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as QuoteRequestStatus) : null) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/quote-requests', { + query: { ...pageParams(params.page), status: params.status, q: params.q }, + }), + }) + + protected readonly labels = QUOTE_REQUEST_STATUS + protected readonly dateTime = formatDateTime + protected solution(r: QuoteRequestAdminDto): string { + return readConfiguratorResult(r.configuratorOutput)?.solution.title ?? '—' + } + + constructor() { + adminTitle()('Richieste di preventivo') + } +} diff --git a/apps/private/src/app/pages/admin/quotes.ts b/apps/private/src/app/pages/admin/quotes.ts new file mode 100644 index 0000000..d7af7a6 --- /dev/null +++ b/apps/private/src/app/pages/admin/quotes.ts @@ -0,0 +1,103 @@ +import type { Paginated, QuoteAdminDto, QuoteStatus } 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 { ApiService } from '../../core/api.service' +import { formatDate, formatDay, formatEuro } from '../../core/format' +import { ADMIN_QUOTE_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common' + +const STATUSES = Object.keys(ADMIN_QUOTE_STATUS) as QuoteStatus[] +const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: ADMIN_QUOTE_STATUS[s].label }))] + +/** + * Preventivi, dal più recente. I nuovi si creano da una richiesta di preventivo o dalla scheda di un utente. + * Il filtro "Scaduto" mostra quelli già segnati come scaduti in DB (succede quando il cliente prova a rispondere). + */ +@Component({ + selector: 'app-admin-quotes', + imports: [RouterLink, Badge, FilterPills, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Preventivi

    +

    Per un nuovo preventivo apri la richiesta del cliente o la sua scheda in Utenti.

    +
    + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + + @for (q of data.items; track q.id) { + + + + + + + + + } + +
    NumeroTitoloClienteImportoValiditàStato
    {{ q.number }}{{ q.title }} + {{ q.customer.companyName ?? name(q.customer) }}
    + {{ q.customer.email }} +
    {{ euro(q.amountCents) }}{{ q.validUntil ? day(q.validUntil) : '—' }} + + @if (q.sentAt) { +
    inviato il {{ date(q.sentAt) }} + } +
    +
    + + } @else { + + } + } + `, +}) +export default class AdminQuotes { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + + protected readonly filters = FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as QuoteStatus) : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status() }), + stream: ({ params }) => + this.api.get>('/admin/quotes', { query: { ...pageParams(params.page), status: params.status } }), + }) + + protected readonly labels = ADMIN_QUOTE_STATUS + protected readonly euro = formatEuro + protected readonly day = formatDay + protected readonly date = formatDate + protected readonly name = fullName + + constructor() { + adminTitle()('Preventivi') + } +} diff --git a/apps/private/src/app/pages/admin/showroom.ts b/apps/private/src/app/pages/admin/showroom.ts new file mode 100644 index 0000000..3b86e44 --- /dev/null +++ b/apps/private/src/app/pages/admin/showroom.ts @@ -0,0 +1,114 @@ +import type { Paginated, ShowroomBookingAdminDto, ShowroomBookingStatus } 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 { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { BOOKING_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common' + +const STATUSES = Object.keys(BOOKING_STATUS) as ShowroomBookingStatus[] +const STATUS_FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: BOOKING_STATUS[s].label }))] +const PERIOD_FILTERS: FilterOption[] = [ + { value: null, label: 'Da oggi' }, + { value: 'tutte', label: 'Anche passate' }, +] + +/** Mezzanotte di oggi (ora locale del browser) in ISO: le visite di oggi restano in elenco tutto il giorno. */ +export function startOfToday(now = new Date()): string { + const d = new Date(now) + d.setHours(0, 0, 0, 0) + return d.toISOString() +} + +/** Prenotazioni dello showroom in ordine di data della visita; di default da oggi in poi. */ +@Component({ + selector: 'app-admin-showroom', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Prenotazioni showroom

    +
    +
    + + +
    + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + @for (b of data.items; track b.id) { + + + + + + + } + +
    VisitaVisitatorePersoneStato
    {{ dateTime(b.requestedAt) }} + {{ b.name }}
    + {{ b.email }}{{ b.phone ? ' · ' + b.phone : '' }} +
    {{ b.people }}
    +
    + + } @else { + + } + } + `, + styles: ` + .filters { + display: grid; + gap: var(--space-2); + } + `, +}) +export default class AdminShowroom { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly periodo = input() + readonly q = input() + + protected readonly statusFilters = STATUS_FILTERS + protected readonly periodFilters = PERIOD_FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as ShowroomBookingStatus) : null)) + protected readonly period = computed(() => (this.periodo() === 'tutte' ? 'tutte' : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), all: this.period() === 'tutte', q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/showroom/bookings', { + query: { ...pageParams(params.page), status: params.status, q: params.q, from: params.all ? null : startOfToday() }, + }), + }) + + protected readonly labels = BOOKING_STATUS + protected readonly dateTime = formatDateTime + + constructor() { + adminTitle()('Prenotazioni showroom') + } +} diff --git a/apps/private/src/app/pages/admin/status-select.ts b/apps/private/src/app/pages/admin/status-select.ts new file mode 100644 index 0000000..aa019f3 --- /dev/null +++ b/apps/private/src/app/pages/admin/status-select.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, effect, input, output } from '@angular/core' +import { FormControl, ReactiveFormsModule } from '@angular/forms' + +let nextId = 0 + +/** Cambio di stato con un ` + @for (o of options(); track o.value) { + + } + + + + `, +}) +export class StatusSelect { + readonly value = input.required() + readonly options = input.required<{ value: string; label: string }[]>() + readonly label = input('Stato') + readonly busy = input(false) + readonly changed = output() + + protected readonly id = `status-${++nextId}` + protected readonly control = new FormControl('', { nonNullable: true }) + + constructor() { + effect(() => this.control.setValue(this.value())) + } + + protected save(): void { + if (this.control.value !== this.value()) this.changed.emit(this.control.value) + } +} diff --git a/apps/private/src/app/pages/admin/ticket-detail.ts b/apps/private/src/app/pages/admin/ticket-detail.ts new file mode 100644 index 0000000..673695c --- /dev/null +++ b/apps/private/src/app/pages/admin/ticket-detail.ts @@ -0,0 +1,228 @@ +import type { TicketAdminDto, TicketDetailDto, TicketPriority, TicketStatus } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { ADMIN_TICKET_STATUS, optionsOf, TICKET_PRIORITY } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { FormField } from '../../shared/form-field' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, fullName, isNotFound } from './admin-common' + +type TicketWithMessages = TicketAdminDto & Pick + +/** + * Conversazione di un ticket lato staff: risposta (email al cliente) con lo stato che il ticket deve assumere + * (di default "in attesa del cliente"), cambio di stato e priorità senza risposta. + */ +@Component({ + selector: 'app-admin-ticket-detail', + imports: [ReactiveFormsModule, RouterLink, Badge, FormField, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Ticket + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Ticket non trovato

    + + } @else if (res.error()) { +

    Ticket

    + + } @else if (res.value(); as t) { +
    +

    {{ t.subject }}

    + +
    +

    + Da {{ name(t.customer) }} ({{ t.customer.email }}) · aperto il + {{ dateTime(t.createdAt) }} · priorità {{ priorities[t.priority].toLowerCase() }} +

    + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    +
    +
      + @for (m of t.messages; track m.id) { +
    1. +

      + {{ m.fromStaff ? 'Assistenza' : name(t.customer) }} + {{ dateTime(m.createdAt) }} +

      +

      {{ m.body }}

      +
    2. + } +
    + +
    +

    Rispondi

    + +
    + + +
    + + +
    + +
    +

    Gestione

    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + } + `, + styles: ` + .thread { + display: grid; + gap: var(--space-3); + margin: 0 0 var(--space-5); + padding: 0; + list-style: none; + + li { + padding: var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + background: var(--color-surface); + } + li.staff { + border-color: var(--color-primary); + background: var(--color-primary-soft); + } + p:last-child { + margin: 0; + } + } + .who { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + h2 { + font-size: var(--font-size-xl); + } + .form-field, + app-form-field, + .after { + margin-bottom: var(--space-4); + } + `, +}) +export default class AdminTicketDetail { + private readonly api = inject(ApiService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/tickets/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + + protected readonly reply = new FormGroup({ + body: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(10000)] }), + status: new FormControl('waiting_customer', { nonNullable: true }), + }) + protected readonly manage = new FormGroup({ + status: new FormControl('open', { nonNullable: true }), + priority: new FormControl('normal', { nonNullable: true }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + + protected readonly statusOptions = optionsOf(ADMIN_TICKET_STATUS) + protected readonly priorityOptions = optionsOf(TICKET_PRIORITY) + protected readonly labels = ADMIN_TICKET_STATUS + protected readonly priorities = TICKET_PRIORITY + protected readonly dateTime = formatDateTime + protected readonly name = fullName + + constructor() { + const setTitle = adminTitle() + effect(() => { + const t = this.res.value() + setTitle(t ? `Ticket: ${t.subject}` : 'Ticket') + if (t) this.manage.setValue({ status: t.status, priority: t.priority }) + }) + } + + protected unchanged(t: TicketWithMessages): boolean { + const v = this.manage.getRawValue() + return v.status === t.status && v.priority === t.priority + } + + protected async send(t: TicketWithMessages): Promise { + this.submitted.set(true) + this.notice.set(null) + const { body, status } = this.reply.getRawValue() + if (this.reply.invalid || body.trim() === '' || this.busy()) { + this.reply.controls.body.setErrors({ required: true }) + return + } + this.busy.set(true) + try { + const updated = await firstValueFrom(this.api.post(`/admin/tickets/${t.id}/messages`, { body: body.trim(), status })) + this.res.set(updated) + this.reply.reset() + this.submitted.set(false) + this.notice.set({ ok: true, text: `Risposta inviata a ${t.customer.email}.` }) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + } + } + + protected async update(t: TicketWithMessages): Promise { + if (this.unchanged(t)) return + const v = this.manage.getRawValue() + this.busy.set(true) + this.notice.set(null) + try { + const updated = await firstValueFrom( + this.api.patch(`/admin/tickets/${t.id}`, { + ...(v.status !== t.status ? { status: v.status } : {}), + ...(v.priority !== t.priority ? { priority: v.priority } : {}), + }), + ) + this.res.set(updated) + this.notice.set({ ok: true, text: 'Ticket aggiornato.' }) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/admin/tickets.ts b/apps/private/src/app/pages/admin/tickets.ts new file mode 100644 index 0000000..e28f645 --- /dev/null +++ b/apps/private/src/app/pages/admin/tickets.ts @@ -0,0 +1,116 @@ +import type { Paginated, TicketAdminDto, TicketPriority, TicketStatus } 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 { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { ADMIN_TICKET_STATUS, TICKET_PRIORITY } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common' + +const STATUSES = Object.keys(ADMIN_TICKET_STATUS) as TicketStatus[] +const PRIORITIES = Object.keys(TICKET_PRIORITY) as TicketPriority[] +const STATUS_FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: ADMIN_TICKET_STATUS[s].label }))] +const PRIORITY_FILTERS: FilterOption[] = [{ value: null, label: 'Ogni priorità' }, ...PRIORITIES.map((p) => ({ value: p, label: TICKET_PRIORITY[p] }))] + +const pick = (values: readonly T[], value: string | undefined): T | null => + (values as readonly string[]).includes(value ?? '') ? (value as T) : null + +/** Ticket di assistenza, aggiornati più di recente in cima: filtri per stato e priorità, ricerca per oggetto o email. */ +@Component({ + selector: 'app-admin-tickets', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Ticket

    +
    +
    + + +
    + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + @for (t of data.items; track t.id) { + + + + + + + + } + +
    OggettoClientePrioritàAggiornatoStato
    {{ t.subject }} + {{ name(t.customer) }}
    + {{ t.customer.email }} +
    {{ priorities[t.priority] }}{{ dateTime(t.updatedAt) }}
    +
    + + } @else { + + } + } + `, + styles: ` + .filters { + display: grid; + gap: var(--space-2); + } + .urgent { + font-weight: var(--font-weight-bold); + color: var(--color-danger); + } + `, +}) +export default class AdminTickets { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + readonly priorita = input() + readonly q = input() + + protected readonly statusFilters = STATUS_FILTERS + protected readonly priorityFilters = PRIORITY_FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => pick(STATUSES, this.stato())) + protected readonly priority = computed(() => pick(PRIORITIES, this.priorita())) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status(), priority: this.priority(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/tickets', { + query: { ...pageParams(params.page), status: params.status, priority: params.priority, q: params.q }, + }), + }) + + protected readonly labels = ADMIN_TICKET_STATUS + protected readonly priorities = TICKET_PRIORITY + protected readonly dateTime = formatDateTime + protected readonly name = fullName + + constructor() { + adminTitle()('Ticket') + } +} diff --git a/apps/private/src/app/pages/admin/upload.ts b/apps/private/src/app/pages/admin/upload.ts new file mode 100644 index 0000000..dc76142 --- /dev/null +++ b/apps/private/src/app/pages/admin/upload.ts @@ -0,0 +1,25 @@ +import type { ApiError } from '../../core/api.service' + +/** Messaggio per gli errori di upload dell'API (tipo non ammesso, troppo grande, file mancante). */ +export function uploadErrorMessage(error: ApiError): string { + switch (error.code) { + case 'UNSUPPORTED_FILE_TYPE': + return 'Tipo di file non ammesso, oppure il contenuto non corrisponde all\'estensione.' + case 'FILE_TOO_LARGE': + return 'Il file è troppo grande.' + case 'FILE_REQUIRED': + return 'Seleziona un file.' + default: + return error.message + } +} + +/** FormData con il file e i campi testuali (multipart di `/admin/documents`, `/admin/uploads/image`…). */ +export function multipart(file: File, fields: Record = {}): FormData { + const body = new FormData() + for (const [key, value] of Object.entries(fields)) { + if (value !== null && value !== undefined && value !== '') body.append(key, value) + } + body.append('file', file, file.name) + return body +} diff --git a/apps/private/src/app/pages/admin/user-detail.ts b/apps/private/src/app/pages/admin/user-detail.ts new file mode 100644 index 0000000..67a2a3b --- /dev/null +++ b/apps/private/src/app/pages/admin/user-detail.ts @@ -0,0 +1,231 @@ +import type { Paginated, QuoteAdminDto, UserAdminDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { AuthStore } from '../../core/auth.store' +import { formatDateTime, formatEuro } from '../../core/format' +import { accountStatus, ADMIN_QUOTE_STATUS, INSTALLER_STATUS } from '../../core/labels' +import { ROLE_LABELS } from '../../core/roles' +import { Badge } from '../../shared/badge' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Empty, Loading, LoadError } from '../../shared/state' +import { adminTitle, fullName, isNotFound } from './admin-common' +import { QuoteForm } from './quote-form' + +type ToggleResult = UserAdminDto & { medusaSynced: boolean | null } + +/** + * Scheda utente: dati dell'account, disabilita/riabilita (chiude tutte le sessioni; per un installatore anche il + * listino riservato), preventivi e creazione di un preventivo intestato all'utente. + */ +@Component({ + selector: 'app-admin-user-detail', + imports: [RouterLink, Badge, ConfirmDialog, QuoteForm, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Utenti + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Utente non trovato

    + + } @else if (res.error()) { +

    Utente

    + + } @else if (res.value(); as u) { +
    +

    {{ name(u) }}

    + +
    + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    +
    +

    Account

    +
    +
    Email
    +
    + {{ u.email }} + · {{ u.emailVerified ? 'verificata' : 'non verificata' }} +
    +
    Ruolo
    +
    {{ roles[u.role] }}
    + @if (u.companyName) { +
    Ragione sociale
    +
    {{ u.companyName }}
    + } + @if (u.vatNumber) { +
    P.IVA
    +
    {{ u.vatNumber }}
    + } + @if (u.installerStatus; as s) { +
    Profilo installatore
    +
    + {{ installerLabels[s].label }} +
    + } +
    Shop
    +
    {{ u.hasShopAccount ? 'cliente collegato in Medusa' : 'non ancora collegato' }}
    +
    Registrato
    +
    {{ dateTime(u.createdAt) }}
    + @if (u.disabledAt) { +
    Disabilitato il
    +
    {{ dateTime(u.disabledAt) }}
    + } +
    + +
    + +
    +

    Accesso

    + @if (isSelf(u)) { +

    Questo è il tuo account.

    + } @else if (u.disabledAt) { +

    + L'utente non può accedere. Se ha chiesto la cancellazione dell'account, i dati vanno rimossi a mano + (vedi procedura): riabilitarlo gli restituisce l'accesso. +

    + + } @else { +

    Disabilitando l'account l'utente esce da tutti i dispositivi e non può più accedere.

    + + } +
    +
    + + @if (u.role !== 'admin') { +
    +

    Preventivi

    + @if (quotes.isLoading()) { + + } @else if (quotes.error()) { + + } @else if (quotes.value()?.items?.length) { + + } @else { +

    Nessun preventivo.

    + } +
    + Nuovo preventivo per {{ name(u) }} + +
    +
    + } + + + } + `, + styles: ` + .links { + margin: var(--space-4) 0 0; + } + .new-quote { + margin-top: var(--space-4); + + summary { + min-height: 44px; + cursor: pointer; + font-weight: var(--font-weight-semibold); + } + app-quote-form { + display: block; + margin-top: var(--space-4); + } + } + `, +}) +export default class AdminUserDetail { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly auth = inject(AuthStore) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/admin/users/${encodeURIComponent(params)}`), + }) + protected readonly quotes = rxResource({ + params: () => (this.res.value()?.role === 'admin' || !this.res.hasValue() ? undefined : this.id()), + stream: ({ params }) => this.api.get>('/admin/quotes', { query: { userId: params, limit: 50 } }), + }) + protected readonly notFound = computed(() => isNotFound(this.res.error())) + protected readonly busy = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + protected readonly dialog = viewChild(ConfirmDialog) + + protected readonly roles = ROLE_LABELS + protected readonly state = accountStatus + protected readonly installerLabels = INSTALLER_STATUS + protected readonly quoteLabels = ADMIN_QUOTE_STATUS + protected readonly dateTime = formatDateTime + protected readonly euro = formatEuro + protected readonly name = fullName + + constructor() { + const setTitle = adminTitle() + effect(() => { + const u = this.res.value() + setTitle(u ? fullName(u) : 'Utente') + }) + } + + protected isSelf(u: UserAdminDto): boolean { + return this.auth.user()?.id === u.id + } + + protected async toggle(u: UserAdminDto): Promise { + if (this.busy()) return + const action = u.disabledAt ? 'enable' : 'disable' + this.busy.set(true) + this.notice.set(null) + try { + const { medusaSynced, ...user } = await firstValueFrom(this.api.post(`/admin/users/${u.id}/${action}`, null)) + this.res.set(user) + const text = action === 'disable' ? 'Account disabilitato: sessioni chiuse.' : 'Account riabilitato.' + this.notice.set( + medusaSynced === false + ? { ok: false, text: `${text} Lo shop non ha risposto: il listino riservato va riallineato dalla scheda installatore.` } + : { ok: true, text }, + ) + } catch (err) { + this.notice.set({ ok: false, text: toApiError(err).message }) + } finally { + this.busy.set(false) + this.dialog()?.close() + } + } + + protected created(quote: QuoteAdminDto): void { + void this.router.navigate(['/admin/preventivi', quote.id], { state: { created: true } }) + } +} diff --git a/apps/private/src/app/pages/admin/users.ts b/apps/private/src/app/pages/admin/users.ts new file mode 100644 index 0000000..7f0366f --- /dev/null +++ b/apps/private/src/app/pages/admin/users.ts @@ -0,0 +1,120 @@ +import type { Paginated, UserAdminDto, UserRole } from '@iti/shared' +import { USER_ROLES } 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 { ApiService } from '../../core/api.service' +import { formatDate } from '../../core/format' +import { accountStatus } from '../../core/labels' +import { ROLE_LABELS } from '../../core/roles' +import { Badge } from '../../shared/badge' +import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common' + +const ROLE_FILTERS: FilterOption[] = [{ value: null, label: 'Tutti i ruoli' }, ...USER_ROLES.map((r) => ({ value: r, label: ROLE_LABELS[r] }))] +const STATUS_FILTERS: FilterOption[] = [ + { value: null, label: 'Tutti' }, + { value: 'active', label: 'Attivi' }, + { value: 'disabled', label: 'Disabilitati' }, +] + +/** Utenti: ricerca per email, nome o ragione sociale; filtri per ruolo e stato dell'account. */ +@Component({ + selector: 'app-admin-users', + imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Utenti

    +
    +
    + + +
    + +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + @for (u of data.items; track u.id) { + + + + + + + + } + +
    NomeEmailRuoloRegistratoStato
    + {{ name(u) }} + @if (u.companyName) { +
    {{ u.companyName }} + } +
    + {{ u.email }} + @if (!u.emailVerified) { +
    non verificata + } +
    {{ roles[u.role] }}{{ date(u.createdAt) }}
    +
    + + } @else { + + } + } + `, + styles: ` + .filters { + display: grid; + gap: var(--space-2); + } + `, +}) +export default class AdminUsers { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly ruolo = input() + readonly stato = input() + readonly q = input() + + protected readonly roleFilters = ROLE_FILTERS + protected readonly statusFilters = STATUS_FILTERS + protected readonly pageSize = ADMIN_PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly role = computed(() => ((USER_ROLES as readonly string[]).includes(this.ruolo() ?? '') ? (this.ruolo() as UserRole) : null)) + protected readonly status = computed(() => (this.stato() === 'active' || this.stato() === 'disabled' ? this.stato()! : null)) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), role: this.role(), status: this.status(), q: this.q() }), + stream: ({ params }) => + this.api.get>('/admin/users', { + query: { ...pageParams(params.page), role: params.role, status: params.status, q: params.q }, + }), + }) + + protected readonly roles = ROLE_LABELS + protected readonly state = accountStatus + protected readonly date = formatDate + protected readonly name = fullName + + constructor() { + adminTitle()('Utenti') + } +} diff --git a/apps/private/src/app/pages/auth/auth-helpers.ts b/apps/private/src/app/pages/auth/auth-helpers.ts new file mode 100644 index 0000000..ae908fa --- /dev/null +++ b/apps/private/src/app/pages/auth/auth-helpers.ts @@ -0,0 +1,30 @@ +import { DestroyRef, inject } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import type { AbstractControl, FormControl, ValidationErrors } from '@angular/forms' +import { ActivatedRoute, Router } from '@angular/router' + +/** Lunghezza minima della password (come l'API). */ +export const PASSWORD_MIN = 10 + +/** + * Campo "ripeti la password": errore `mismatch` se diverso da `password`, ricontrollato quando cambia + * la password. Da chiamare nel costruttore. + */ +export function mustMatch(password: FormControl, confirm: FormControl): void { + confirm.addValidators((c: AbstractControl): ValidationErrors | null => (c.value === password.value ? null : { mismatch: true })) + password.valueChanges.pipe(takeUntilDestroyed(inject(DestroyRef))).subscribe(() => confirm.updateValueAndValidity()) +} + +/** + * Legge `?token=` dei link delle email (reset password, verifica email) e lo toglie subito dall'indirizzo + * (`replaceUrl`): non resta nella cronologia, nei preferiti o negli screenshot. Da chiamare nel costruttore. + */ +export function takeTokenFromUrl(): string | null { + const route = inject(ActivatedRoute) + const router = inject(Router) + const token = route.snapshot.queryParamMap.get('token') + if (token !== null) { + void router.navigate([], { relativeTo: route, queryParams: { token: null }, queryParamsHandling: 'merge', replaceUrl: true }) + } + return token && token.length >= 20 && token.length <= 200 ? token : null +} diff --git a/apps/private/src/app/pages/auth/forgot-password.ts b/apps/private/src/app/pages/auth/forgot-password.ts new file mode 100644 index 0000000..612c38a --- /dev/null +++ b/apps/private/src/app/pages/auth/forgot-password.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { authErrorMessage } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' + +/** Richiesta del link per reimpostare la password. La risposta è sempre la stessa (anti-enumerazione). */ +@Component({ + selector: 'app-forgot-password', + imports: [ReactiveFormsModule, RouterLink, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Password dimenticata

    + @if (done(); as message) { +

    {{ message }}

    +

    Il link vale un'ora. Se non trovi l'email, controlla anche nella posta indesiderata.

    + Torna al login + } @else { +

    Inserisci l'email del tuo account: ti invieremo un link per scegliere una nuova password.

    + @if (error(); as message) { + + } +
    + + + +

    Torna al login

    + } + `, + styles: ` + app-form-field { + margin-bottom: var(--space-5); + } + .back { + margin: var(--space-5) 0 0; + text-align: center; + } + `, +}) +export default class ForgotPassword { + private readonly api = inject(ApiService) + + protected readonly form = new FormGroup({ + email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + protected readonly done = signal(null) + + constructor() { + inject(Title).setTitle('Password dimenticata | Area riservata ITI TECHNOLOGY') + } + + protected async submit(): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.form.invalid || this.busy()) return + this.busy.set(true) + try { + const res = await firstValueFrom( + this.api.post<{ message: string }>('/auth/forgot-password', { email: this.form.getRawValue().email.trim() }, { anonymous: true }), + ) + this.done.set(res.message) + } catch (err) { + this.error.set(authErrorMessage(toApiError(err))) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/auth/login.ts b/apps/private/src/app/pages/auth/login.ts new file mode 100644 index 0000000..a450eb4 --- /dev/null +++ b/apps/private/src/app/pages/auth/login.ts @@ -0,0 +1,104 @@ +import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { ApiError, toApiError } from '../../core/api.service' +import { AuthStore } from '../../core/auth.store' +import { authErrorMessage } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' + +/** + * Login. `?redirect=` (validato in core/redirect.ts) indica dove tornare: una pagina dell'area riservata + * o del sito pubblico (es. `/checkout`). `?scaduta=1` = sessione persa durante l'uso; + * `?account=disattivato` = account appena disattivato dall'utente. + */ +@Component({ + selector: 'app-login', + imports: [ReactiveFormsModule, RouterLink, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Accedi

    + @if (account() === 'disattivato') { +

    + Il tuo account è stato disattivato e la richiesta di cancellazione è stata inviata. Grazie per averci scelto. +

    + } + @if (scaduta()) { +

    La sessione è scaduta: accedi di nuovo per continuare.

    + } + @if (error(); as message) { + + } +
    +
    + + +
    +

    Password dimenticata?

    + +
    +

    + Non hai un account? + Registrati +

    + `, + styles: ` + .single { + grid-template-columns: 1fr; + } + .forgot { + margin: var(--space-2) 0 var(--space-5); + text-align: right; + } + .switch { + margin: var(--space-5) 0 0; + text-align: center; + } + `, +}) +export default class Login { + private readonly auth = inject(AuthStore) + + readonly redirect = input() + readonly scaduta = input() + /** `disattivato` dopo la richiesta di cancellazione dell'account. */ + readonly account = input() + + protected readonly form = new FormGroup({ + email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + password: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + + constructor() { + inject(Title).setTitle('Accedi | Area riservata ITI TECHNOLOGY') + } + + protected async submit(): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.form.invalid || this.busy()) return + this.busy.set(true) + try { + const { email, password } = this.form.getRawValue() + await this.auth.login(email.trim(), password) + await this.auth.navigateAfterLogin(this.redirect() ?? null) + } catch (err) { + const error = err instanceof ApiError ? err : toApiError(err) + this.error.set(error.code === 'INVALID_CREDENTIALS' ? 'Email o password non corretti.' : authErrorMessage(error)) + this.form.controls.password.reset() + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/auth/register.html b/apps/private/src/app/pages/auth/register.html new file mode 100644 index 0000000..4171d2d --- /dev/null +++ b/apps/private/src/app/pages/auth/register.html @@ -0,0 +1,126 @@ +

    Crea un account

    + +@if (done(); as message) { +

    {{ message }}

    + @if (accountType() === 'installer') { +

    + Dopo la conferma dell'email potrai accedere e completare il profilo aziendale. Il listino riservato e la + documentazione tecnica saranno disponibili quando avremo approvato la tua richiesta. +

    + } + Vai al login +} @else { + @if (error(); as message) { + + } + +
    +
    + Tipo di account +
    + @for (type of types; track type.value) { + + } +
    +
    + +
    + I tuoi dati +
    + + + @if (accountType() !== 'customer') { + + + } + +
    +
    + +
    + Accesso +
    + + + +
    +
    + +
    + + @if (submitted() && form.controls.acceptPrivacy.invalid) { +

    Il consenso è necessario per creare l'account.

    + } + +
    + + +
    + +

    + Hai già un account? Accedi +

    +} diff --git a/apps/private/src/app/pages/auth/register.ts b/apps/private/src/app/pages/auth/register.ts new file mode 100644 index 0000000..df1d8b3 --- /dev/null +++ b/apps/private/src/app/pages/auth/register.ts @@ -0,0 +1,182 @@ +import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { applyServerErrors, authErrorMessage } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { mustMatch, PASSWORD_MIN } from './auth-helpers' + +export type AccountType = 'customer' | 'company' | 'installer' + +export const ACCOUNT_TYPES: { value: AccountType; label: string; hint: string }[] = [ + { value: 'customer', label: 'Privato', hint: 'Acquisti, preventivi e assistenza per la tua casa.' }, + { value: 'company', label: 'Azienda', hint: 'Fatturazione aziendale: servono ragione sociale e partita IVA.' }, + { + value: 'installer', + label: 'Installatore', + hint: 'Listino riservato e documentazione tecnica, dopo l’approvazione del profilo da parte nostra.', + }, +] + +/** Corpo di `POST /auth/register`: solo i campi pertinenti al tipo di account, niente stringhe vuote. */ +export function registerBody(value: { + accountType: AccountType + firstName: string + lastName: string + companyName: string + vatNumber: string + phone: string + email: string + password: string + acceptMarketing: boolean +}): Record { + const optional = (v: string) => (v.trim() === '' ? undefined : v.trim()) + const business = value.accountType !== 'customer' + return { + accountType: value.accountType, + firstName: value.firstName.trim(), + lastName: value.lastName.trim(), + email: value.email.trim(), + password: value.password, + companyName: business ? optional(value.companyName) : undefined, + vatNumber: business ? optional(value.vatNumber) : undefined, + phone: optional(value.phone), + acceptPrivacy: true, + acceptMarketing: value.acceptMarketing, + } +} + +/** + * Registrazione: privato (customer), azienda (company: ragione sociale e P.IVA obbligatorie) o installatore + * (ragione sociale obbligatoria; il profilo resta "in attesa" finché un admin non lo approva). + * L'API risponde sempre 202 con lo stesso messaggio (anche se l'email esiste già): niente login automatico. + * `?email=` precompila l'indirizzo (link "Crea un account" dalla conferma d'ordine del sito). + */ +@Component({ + selector: 'app-register', + imports: [ReactiveFormsModule, RouterLink, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './register.html', + styles: ` + .types { + display: grid; + gap: var(--space-2); + } + .type { + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + + &:has(input:checked) { + border-color: var(--color-primary); + background: var(--color-primary-soft); + } + &:has(input:focus-visible) { + outline: 3px solid var(--color-focus); + outline-offset: 2px; + } + small { + display: block; + color: var(--color-ink-muted); + } + } + .consents { + margin-top: var(--space-5); + } + .switch { + margin: var(--space-5) 0 0; + text-align: center; + } + `, +}) +export default class Register { + private readonly api = inject(ApiService) + protected readonly siteUrl = inject(APP_CONFIG).siteUrl + protected readonly types = ACCOUNT_TYPES + + readonly email = input() + readonly redirect = input() + + protected readonly form = new FormGroup({ + accountType: new FormControl('customer', { nonNullable: true }), + firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), + lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), + companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }), + vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }), + phone: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(30)] }), + email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + password: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)], + }), + confirm: new FormControl('', { nonNullable: true }), + acceptPrivacy: new FormControl(false, { nonNullable: true, validators: [Validators.requiredTrue] }), + acceptMarketing: new FormControl(false, { nonNullable: true }), + }) + + protected readonly accountType = signal('customer') + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + protected readonly done = signal(null) + + constructor() { + inject(Title).setTitle('Registrati | Area riservata ITI TECHNOLOGY') + const { controls } = this.form + mustMatch(controls.password, controls.confirm) + controls.accountType.valueChanges.pipe(takeUntilDestroyed()).subscribe((type) => this.applyAccountType(type)) + + effect(() => { + const email = this.email() + if (email && controls.email.value === '') controls.email.setValue(email) + }) + } + + /** Campi aziendali obbligatori secondo il tipo di account (stesse regole dell'API). */ + private applyAccountType(type: AccountType): void { + this.accountType.set(type) + const { companyName, vatNumber } = this.form.controls + companyName.setValidators(type === 'customer' ? [Validators.maxLength(200)] : [Validators.required, Validators.maxLength(200)]) + vatNumber.setValidators( + type === 'company' + ? [Validators.required, Validators.minLength(5), Validators.maxLength(20)] + : [Validators.minLength(5), Validators.maxLength(20)], + ) + companyName.updateValueAndValidity() + vatNumber.updateValueAndValidity() + } + + protected async submit(): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.form.invalid || this.busy()) { + this.focusFirstInvalid() + return + } + this.busy.set(true) + try { + const res = await firstValueFrom( + this.api.post<{ message: string }>('/auth/register', registerBody(this.form.getRawValue()), { anonymous: true }), + ) + this.done.set(res.message) + } catch (err) { + const error = toApiError(err) + if (error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error)) { + this.error.set('Controlla i campi evidenziati.') + this.focusFirstInvalid() + } else { + this.error.set(authErrorMessage(error)) + } + } finally { + this.busy.set(false) + } + } + + private focusFirstInvalid(): void { + queueMicrotask(() => document.querySelector('app-register [aria-invalid="true"]')?.focus()) + } +} diff --git a/apps/private/src/app/pages/auth/reset-password.ts b/apps/private/src/app/pages/auth/reset-password.ts new file mode 100644 index 0000000..0a3cb8c --- /dev/null +++ b/apps/private/src/app/pages/auth/reset-password.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { authErrorMessage } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { mustMatch, PASSWORD_MIN, takeTokenFromUrl } from './auth-helpers' + +/** + * Nuova password dal link dell'email (`/reset-password?token=…`, 1 ora, monouso). Il token viene tolto + * dall'indirizzo appena letto. Il reset chiude tutte le sessioni dell'utente: dopo si accede di nuovo. + */ +@Component({ + selector: 'app-reset-password', + imports: [ReactiveFormsModule, RouterLink, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Nuova password

    + @if (!token) { + + Richiedi un nuovo link + } @else if (done()) { +

    Password aggiornata. Per sicurezza abbiamo chiuso tutte le sessioni aperte.

    + Accedi con la nuova password + } @else if (invalid()) { + + Richiedi un nuovo link + } @else { + @if (error(); as message) { + + } +
    +
    + + +
    + +
    + } + `, + styles: ` + .single { + grid-template-columns: 1fr; + margin-bottom: var(--space-5); + } + `, +}) +export default class ResetPassword { + private readonly api = inject(ApiService) + protected readonly token = takeTokenFromUrl() + + protected readonly form = new FormGroup({ + password: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)], + }), + confirm: new FormControl('', { nonNullable: true }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly done = signal(false) + protected readonly invalid = signal(false) + protected readonly error = signal(null) + + constructor() { + inject(Title).setTitle('Nuova password | Area riservata ITI TECHNOLOGY') + mustMatch(this.form.controls.password, this.form.controls.confirm) + } + + protected async submit(): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.form.invalid || this.busy() || !this.token) return + this.busy.set(true) + try { + await firstValueFrom( + this.api.post('/auth/reset-password', { token: this.token, password: this.form.getRawValue().password }, { anonymous: true }), + ) + this.done.set(true) + } catch (err) { + const error = toApiError(err) + if (error.code === 'INVALID_TOKEN' || (error.code === 'VALIDATION_ERROR' && error.details.some((d) => d.path === 'body.token'))) { + this.invalid.set(true) + } else { + this.error.set(authErrorMessage(error)) + } + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/auth/verify-email.ts b/apps/private/src/app/pages/auth/verify-email.ts new file mode 100644 index 0000000..0afc76a --- /dev/null +++ b/apps/private/src/app/pages/auth/verify-email.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { AuthStore } from '../../core/auth.store' +import { homeFor } from '../../core/roles' +import { authErrorMessage } from '../../shared/form-errors' +import { takeTokenFromUrl } from './auth-helpers' + +type State = 'verifying' | 'verified' | 'invalid' | 'error' | 'missing' + +/** + * Conferma dell'email dal link (`/verify-email?token=…`, 48 ore, monouso). Vale sia da anonimi sia con una + * sessione aperta: in quel caso l'utente viene riletto, così l'avviso "conferma la tua email" sparisce. + * La verifica collega anche gli ordini fatti come ospite e le richieste anonime con la stessa email (API). + */ +@Component({ + selector: 'app-verify-email', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Conferma dell'email

    + @switch (state()) { + @case ('verifying') { +

    Verifica in corso…

    + } + @case ('verified') { +

    Email confermata, grazie!

    +

    Gli eventuali ordini fatti come ospite con questo indirizzo sono ora collegati al tuo account.

    + {{ auth.isAuthenticated() ? 'Vai alla tua area' : 'Accedi' }} + } + @case ('invalid') { + +

    Per riceverne uno nuovo accedi: in cima all'area riservata trovi "Invia di nuovo il link".

    + {{ auth.isAuthenticated() ? 'Vai alla tua area' : 'Accedi' }} + } + @case ('missing') { + + } + @default { + + + } + } + `, +}) +export default class VerifyEmail { + private readonly api = inject(ApiService) + protected readonly auth = inject(AuthStore) + private readonly token = takeTokenFromUrl() + + protected readonly state = signal(this.token ? 'verifying' : 'missing') + protected readonly error = signal('') + protected readonly next = computed(() => { + const role = this.auth.role() + return role ? homeFor(role) : '/login' + }) + + constructor() { + inject(Title).setTitle("Conferma dell'email | Area riservata ITI TECHNOLOGY") + void this.verify() + } + + protected async verify(): Promise { + if (!this.token) return + this.state.set('verifying') + try { + await firstValueFrom(this.api.post('/auth/verify-email', { token: this.token }, { anonymous: true })) + this.state.set('verified') + await this.auth.ready() + await this.auth.reloadUser().catch(() => undefined) + } catch (err) { + const error = toApiError(err) + if (error.code === 'INVALID_TOKEN' || error.code === 'VALIDATION_ERROR') { + this.state.set('invalid') + } else { + this.error.set(authErrorMessage(error)) + this.state.set('error') + } + } + } +} diff --git a/apps/private/src/app/pages/customer/customer.spec.ts b/apps/private/src/app/pages/customer/customer.spec.ts new file mode 100644 index 0000000..aeadda6 --- /dev/null +++ b/apps/private/src/app/pages/customer/customer.spec.ts @@ -0,0 +1,172 @@ +import type { DocumentDto, QuoteDto } 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 { provideRouter } from '@angular/router' +import { APP_CONFIG } from '../../core/app-config' +import { authInterceptor } from '../../core/auth.interceptor' +import { fileName } from '../../core/download.service' +import { formatDay, formatEuro } from '../../core/format' +import { orderStatus } from '../../core/labels' +import { pageFromQuery } from '../../shared/pager' +import { fileKind, groupByCategory } from './documents' +import { profilePatch } from './profile' +import QuoteDetail from './quote-detail' + +const API = 'http://api.test' +const tick = () => new Promise((resolve) => setTimeout(resolve)) +/** Lascia finire promise, richieste e change detection. */ +const settle = async (fixture: { whenStable(): Promise }) => { + await tick() + await tick() + await fixture.whenStable() +} + +describe('area clienti: funzioni', () => { + it('stato dell’ordine in una sola etichetta', () => { + const o = (status: string, paymentStatus: string, fulfillmentStatus: string) => orderStatus({ status, paymentStatus, fulfillmentStatus }) + expect(o('canceled', 'refunded', 'shipped').label).toBe('Annullato') + expect(o('pending', 'captured', 'delivered')).toEqual({ label: 'Consegnato', tone: 'success' }) + expect(o('pending', 'captured', 'shipped').label).toBe('Spedito') + expect(o('pending', 'captured', 'fulfilled').label).toBe('In preparazione') + expect(o('pending', 'captured', 'not_fulfilled').label).toBe('Confermato') + expect(o('pending', 'not_paid', 'not_fulfilled')).toEqual({ label: 'In attesa di pagamento', tone: 'warning' }) + expect(o('completed', 'captured', 'not_fulfilled').label).toBe('Completato') + }) + + it('documenti per categoria, "Altri documenti" in fondo', () => { + const doc = (id: string, category: string | null): DocumentDto => ({ + id, + title: id, + category, + mimeType: 'application/pdf', + sizeBytes: 1, + visibility: 'customer', + productRef: null, + createdAt: '2026-01-01T00:00:00.000Z', + }) + const groups = groupByCategory([doc('a', 'Manuali'), doc('b', null), doc('c', 'Certificazioni'), doc('d', 'Manuali'), doc('e', ' ')]) + expect(groups.map((g) => [g.category, g.docs.map((d) => d.id)])).toEqual([ + ['Certificazioni', ['c']], + ['Manuali', ['a', 'd']], + ['Altri documenti', ['b', 'e']], + ]) + expect(fileKind('image/vnd.dwg')).toBe('DWG') + }) + + it('nome del file scaricato: caratteri non ammessi tolti, estensione dal MIME', () => { + expect(fileName('Scheda: pompa 8 kW / 2026', 'application/pdf')).toBe('Scheda pompa 8 kW 2026.pdf') + expect(fileName('manuale.pdf', 'application/pdf')).toBe('manuale.pdf') + expect(fileName(' ', 'image/vnd.dwg')).toBe('documento.dwg') + }) + + it('PATCH /me: campi aziendali solo per le aziende', () => { + const value = { firstName: ' Ada ', lastName: 'Rossi', companyName: ' Rossi Srl ', vatNumber: '123' } + expect(profilePatch('customer', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi' }) + expect(profilePatch('installer', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi' }) + expect(profilePatch('company', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi', companyName: 'Rossi Srl', vatNumber: '123' }) + }) + + it('pagina da ?pagina=, importi e date', () => { + expect([pageFromQuery(undefined), pageFromQuery('3'), pageFromQuery('0'), pageFromQuery('abc'), pageFromQuery('1.5')]).toEqual([1, 3, 1, 1, 1]) + expect(formatEuro(549000)).toBe('5.490 €') + expect(formatDay('2026-10-31')).toBe('31 ottobre 2026') + }) +}) + +describe('dettaglio preventivo', () => { + const quote: QuoteDto = { + id: '11111111-1111-4111-8111-111111111111', + quoteRequestId: null, + number: '2026-0007', + title: 'Impianto FV 6 kW', + status: 'sent', + amountCents: 990000, + hasPdf: true, + validUntil: '2026-12-31', + sentAt: '2026-10-01T10:00:00.000Z', + createdAt: '2026-09-30T10:00:00.000Z', + } + + beforeAll(() => { + // jsdom non implementa (o implementa in parte) + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.setAttribute('open', '') + } + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.removeAttribute('open') + } + }) + + async function render() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + provideRouter([]), + { provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: 'http://sito.test', medusaAdminUrl: 'http://medusa.test/app' } }, + ], + }) + const http = TestBed.inject(HttpTestingController) + const fixture = TestBed.createComponent(QuoteDetail) + fixture.componentRef.setInput('id', quote.id) + fixture.detectChanges() + await tick() + http.expectOne(`${API}/me/quotes/${quote.id}`).flush(quote) + await fixture.whenStable() + return { fixture, http, el: fixture.nativeElement as HTMLElement } + } + + const button = (el: HTMLElement, text: string) => + [...el.querySelectorAll('button')].find((b) => b.textContent?.trim().startsWith(text)) as HTMLButtonElement + + it('accetta: conferma, POST e stato aggiornato in pagina senza ricaricare', async () => { + const { fixture, http, el } = await render() + expect(el.textContent).toContain('Da valutare') + button(el, 'Accetta il preventivo').click() + await fixture.whenStable() + expect(el.querySelector('dialog')?.hasAttribute('open')).toBe(true) + + button(el, 'Sì, accetto').click() + await tick() + const req = http.expectOne(`${API}/me/quotes/${quote.id}/accept`) + expect(req.request.method).toBe('POST') + req.flush({ ...quote, status: 'accepted' }) + await settle(fixture) + + expect(el.textContent).toContain('Accettato') + expect(el.textContent).toContain('Preventivo accettato') + expect(button(el, 'Accetta il preventivo')).toBeUndefined() + http.expectNone(`${API}/me/quotes/${quote.id}`) // nessuna rilettura + http.verify() + }) + + it('annulla: nessuna richiesta', async () => { + const { fixture, http, el } = await render() + button(el, 'Rifiuta').click() + await fixture.whenStable() + button(el, 'Annulla').click() + await fixture.whenStable() + http.verify() + expect(el.textContent).toContain('Da valutare') + }) + + it('409 (già scaduto): messaggio e preventivo riletto', async () => { + const { fixture, http, el } = await render() + button(el, 'Accetta il preventivo').click() + await fixture.whenStable() + button(el, 'Sì, accetto').click() + await tick() + http + .expectOne(`${API}/me/quotes/${quote.id}/accept`) + .flush({ error: { code: 'QUOTE_EXPIRED', message: 'Il preventivo è scaduto' } }, { status: 409, statusText: 'Conflict' }) + // niente whenStable qui: la rilettura in corso è un'attività pendente e lo terrebbe in attesa + await tick() + await tick() + http.expectOne(`${API}/me/quotes/${quote.id}`).flush({ ...quote, status: 'expired' }) + await settle(fixture) + expect(el.textContent).toContain('Il preventivo è scaduto') + expect(el.textContent).toContain('Scaduto') + http.verify() + }) +}) diff --git a/apps/private/src/app/pages/customer/dashboard.ts b/apps/private/src/app/pages/customer/dashboard.ts new file mode 100644 index 0000000..0b08ba0 --- /dev/null +++ b/apps/private/src/app/pages/customer/dashboard.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { Title } from '@angular/platform-browser' +import { AuthStore } from '../../core/auth.store' +import { CustomerOverview } from './overview' + +/** Panoramica dell'area clienti (privati e aziende). */ +@Component({ + selector: 'app-dashboard', + imports: [CustomerOverview], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Ciao {{ auth.user()?.firstName }}

    +

    Qui trovi ordini, preventivi e richieste di assistenza.

    + + `, +}) +export default class Dashboard { + protected readonly auth = inject(AuthStore) + + constructor() { + inject(Title).setTitle('Panoramica | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/pages/customer/documents.ts b/apps/private/src/app/pages/customer/documents.ts new file mode 100644 index 0000000..e6a1833 --- /dev/null +++ b/apps/private/src/app/pages/customer/documents.ts @@ -0,0 +1,113 @@ +import type { DocumentDto, Paginated } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { map } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { DownloadService, fileName } from '../../core/download.service' +import { formatBytes, formatDate } from '../../core/format' +import { Empty, Loading, LoadError } from '../../shared/state' + +const OTHER = 'Altri documenti' + +/** Etichetta del tipo di file dal MIME canonico salvato dall'API. */ +export function fileKind(mimeType: string): string { + const kinds: Record = { + 'application/pdf': 'PDF', + 'application/zip': 'ZIP', + 'image/vnd.dwg': 'DWG', + 'image/vnd.dxf': 'DXF', + 'image/jpeg': 'JPG', + 'image/png': 'PNG', + 'image/webp': 'WebP', + 'image/gif': 'GIF', + } + return kinds[mimeType] ?? 'File' +} + +/** Documenti raggruppati per categoria (alfabetica, "Altri documenti" in fondo). */ +export function groupByCategory(docs: DocumentDto[]): { category: string; docs: DocumentDto[] }[] { + const groups = new Map() + for (const doc of docs) { + const key = doc.category?.trim() || OTHER + groups.set(key, [...(groups.get(key) ?? []), doc]) + } + return [...groups.entries()] + .sort(([a], [b]) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b, 'it'))) + .map(([category, list]) => ({ category, docs: list })) +} + +/** + * Documenti scaricabili con il proprio ruolo (l'API filtra per visibilità: pubblici e clienti; per gli + * installatori approvati anche quelli tecnici). Il download passa dal token (DownloadService). + */ +@Component({ + selector: 'app-documents', + imports: [Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Documenti

    +

    Manuali, schede tecniche e altri documenti messi a disposizione per te.

    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (groups().length) { + @if (downloadError(); as message) { + + } + @for (g of groups(); track g.category) { +
    +

    {{ g.category }}

    +
      + @for (d of g.docs; track d.id) { +
    • + + {{ d.title }} + {{ kind(d.mimeType) }} · {{ bytes(d.sizeBytes) }} · {{ date(d.createdAt) }} + + +
    • + } +
    +
    + } + } @else { + + } + `, +}) +export default class Documents { + private readonly api = inject(ApiService) + private readonly downloads = inject(DownloadService) + + protected readonly res = rxResource({ + stream: () => this.api.get>('/documents', { query: { limit: 100 } }).pipe(map((p) => p.items)), + }) + protected readonly groups = computed(() => groupByCategory(this.res.value() ?? [])) + protected readonly downloading = signal(null) + protected readonly downloadError = signal(null) + + protected readonly kind = fileKind + protected readonly bytes = formatBytes + protected readonly date = formatDate + + constructor() { + inject(Title).setTitle('Documenti | Area riservata ITI TECHNOLOGY') + } + + protected async download(doc: DocumentDto): Promise { + this.downloadError.set(null) + this.downloading.set(doc.id) + try { + await this.downloads.download(`/documents/${doc.id}/download`, fileName(doc.title, doc.mimeType)) + } catch (err) { + this.downloadError.set(`Download di "${doc.title}" non riuscito: ${toApiError(err).message}`) + } finally { + this.downloading.set(null) + } + } +} diff --git a/apps/private/src/app/pages/customer/order-detail.ts b/apps/private/src/app/pages/customer/order-detail.ts new file mode 100644 index 0000000..5c5d787 --- /dev/null +++ b/apps/private/src/app/pages/customer/order-detail.ts @@ -0,0 +1,203 @@ +import type { MyOrderDetailDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { ApiError, ApiService } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatDate, formatEuro } from '../../core/format' +import { orderStatus } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Empty, Loading, LoadError } from '../../shared/state' + +/** Dettaglio ordine: stato, articoli, totali con IVA, spedizione e tracking. */ +@Component({ + selector: 'app-order-detail', + imports: [RouterLink, Badge, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← I miei ordini + + @if (res.isLoading()) { + + } @else if (notFound()) { +

    Ordine non trovato

    + + } @else if (res.error()) { +

    Ordine n. {{ numero() }}

    + + } @else if (res.value(); as o) { +
    +

    Ordine n. {{ o.number }}

    + +
    +

    Effettuato il {{ date(o.createdAt) }}

    + +
    +
    +

    Articoli

    +
      + @for (i of o.items; track $index) { +
    • + @if (i.thumbnail) { + + } + + {{ i.title }} + + {{ i.quantity }} × {{ euro(i.unitPrice) }} + @if (i.sku) { + · cod. {{ i.sku }} + } + + + {{ euro(i.total) }} +
    • + } +
    +
    +
    Prodotti
    +
    {{ euro(o.totals.items) }}
    + @if (o.totals.discount > 0) { +
    Sconto
    +
    −{{ euro(o.totals.discount) }}
    + } +
    Spedizione
    +
    {{ o.totals.shipping > 0 ? euro(o.totals.shipping) : 'Gratuita' }}
    +
    Totale
    +
    {{ euro(o.totals.total) }}
    +
    di cui IVA
    +
    {{ euro(o.totals.tax) }}
    +
    +
    + +
    +

    Spedizione

    + @if (o.shippingAddress.length) { +
    + @for (line of o.shippingAddress; track $index) { + {{ line }}
    + } +
    + } + @if (o.shippingMethods.length) { +

    Metodo: {{ o.shippingMethods.join(', ') }}

    + } + @if (o.tracking.length) { +

    Tracciamento

    +
      + @for (t of o.tracking; track t.number) { +
    • + @if (safeUrl(t.url); as url) { + {{ t.number }} + } @else { + {{ t.number }} + } +
    • + } +
    + } @else if (o.status !== 'canceled') { +

    Il codice di tracciamento comparirà qui quando l'ordine sarà spedito.

    + } +
    +
    + +

    + Serve aiuto con questo ordine? + Apri una richiesta di assistenza + oppure contattaci. +

    + } + `, + styles: ` + .layout { + display: grid; + gap: var(--space-5); + margin-top: var(--space-4); + + @media (min-width: 1024px) { + grid-template-columns: 2fr 1fr; + align-items: start; + } + } + .lines { + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; + + li { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--color-line); + } + img { + flex: none; + width: 56px; + height: 56px; + object-fit: cover; + border-radius: var(--radius-sm); + } + } + .line-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + } + .totals { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-1) var(--space-4); + margin: 0; + + dd { + margin: 0; + text-align: right; + } + .total { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-lg); + } + } + address { + font-style: normal; + margin-bottom: var(--space-3); + } + h3 { + font-size: var(--font-size-lg); + } + .tracking { + padding-left: var(--space-5); + } + `, +}) +export default class OrderDetail { + private readonly api = inject(ApiService) + protected readonly contactUrl = `${inject(APP_CONFIG).siteUrl}/contatti` + readonly numero = input.required() + + protected readonly res = rxResource({ + params: () => this.numero(), + stream: ({ params }) => this.api.get(`/me/orders/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => { + const err = this.res.error() + return err instanceof ApiError && (err.status === 404 || err.status === 400) + }) + + protected readonly status = orderStatus + protected readonly date = formatDate + protected readonly euro = formatEuro + + /** Solo link http(s): l'URL di tracking arriva dal corriere tramite Medusa. */ + protected safeUrl(url: string | null): string | null { + return url && /^https?:\/\//i.test(url) ? url : null + } + + constructor() { + const title = inject(Title) + effect(() => title.setTitle(`Ordine n. ${this.numero()} | Area riservata ITI TECHNOLOGY`)) + } +} diff --git a/apps/private/src/app/pages/customer/orders.ts b/apps/private/src/app/pages/customer/orders.ts new file mode 100644 index 0000000..3ea1155 --- /dev/null +++ b/apps/private/src/app/pages/customer/orders.ts @@ -0,0 +1,82 @@ +import type { MyOrderDto, Paginated } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { ApiService } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatDate, formatEuro } from '../../core/format' +import { orderStatus } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' + +const PAGE_SIZE = 10 + +/** I miei ordini (da Medusa, compresi gli ordini fatti come ospite e collegati alla verifica dell'email). */ +@Component({ + selector: 'app-orders', + imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    I miei ordini

    + Vai allo shop +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { + + + } @else { + +

    Gli ordini fatti come ospite con la tua email compaiono qui dopo la conferma dell'indirizzo.

    +
    + } + } + `, +}) +export default class Orders { + private readonly api = inject(ApiService) + protected readonly shopUrl = `${inject(APP_CONFIG).siteUrl}/shop` + readonly pagina = input() + + protected readonly pageSize = PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly res = rxResource({ + params: () => this.page(), + stream: ({ params }) => + this.api.get>('/me/orders', { query: { limit: PAGE_SIZE, offset: (params - 1) * PAGE_SIZE } }), + }) + + protected readonly status = orderStatus + protected readonly date = formatDate + protected readonly euro = formatEuro + protected count(o: MyOrderDto): string { + const n = o.items.reduce((sum, i) => sum + i.quantity, 0) + return n === 1 ? '1 articolo' : `${n} articoli` + } + + constructor() { + inject(Title).setTitle('I miei ordini | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/pages/customer/overview.ts b/apps/private/src/app/pages/customer/overview.ts new file mode 100644 index 0000000..8dd7e8b --- /dev/null +++ b/apps/private/src/app/pages/customer/overview.ts @@ -0,0 +1,158 @@ +import type { MyOrderDto, Paginated, QuoteDto, TicketDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { RouterLink } from '@angular/router' +import { map } from 'rxjs' +import { ApiService } from '../../core/api.service' +import { formatDate, formatEuro } from '../../core/format' +import { isQuotePending, isTicketOpen, orderStatus, QUOTE_STATUS, TICKET_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Empty, Loading, LoadError } from '../../shared/state' + +/** Quanti elementi per riquadro. */ +const LIMIT = 3 + +/** + * Riepilogo dell'area clienti: ultimi ordini, preventivi da valutare, ticket aperti. Ogni riquadro si carica + * e fallisce da solo (es. Medusa giù non blocca preventivi e ticket). Riusabile nella home installatori. + */ +@Component({ + selector: 'app-customer-overview', + imports: [RouterLink, Badge, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +
    +
    +

    Ultimi ordini

    + Tutti +
    + @if (orders.isLoading()) { + + } @else if (orders.error()) { + + } @else if (orders.value()?.length) { + + } @else { + + } +
    + +
    +
    +

    Preventivi da valutare

    + Tutti +
    + @if (quotes.isLoading()) { + + } @else if (quotes.error()) { + + } @else if (pendingQuotes().length) { + + } @else { + + } +
    + +
    +
    +

    Richieste di assistenza aperte

    + Tutte +
    + @if (tickets.isLoading()) { + + } @else if (tickets.error()) { + + } @else if (openTickets().length) { + + } @else { + + Apri una richiesta + + } +
    +
    + `, + styles: ` + .grid { + display: grid; + gap: var(--space-5); + } + .head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); + + h2 { + margin: 0; + font-size: var(--font-size-xl); + } + a { + display: inline-flex; + align-items: center; + min-height: 44px; + } + } + `, +}) +export class CustomerOverview { + private readonly api = inject(ApiService) + + protected readonly orders = rxResource({ + stream: () => this.api.get>('/me/orders', { query: { limit: LIMIT } }).pipe(map((p) => p.items)), + }) + // L'API filtra per un solo stato: si leggono gli ultimi e si filtra qui (pochi elementi per cliente) + protected readonly quotes = rxResource({ + stream: () => this.api.get>('/me/quotes', { query: { limit: 50 } }).pipe(map((p) => p.items)), + }) + protected readonly tickets = rxResource({ + stream: () => this.api.get>('/me/tickets', { query: { limit: 50 } }).pipe(map((p) => p.items)), + }) + + protected readonly pendingQuotes = computed(() => (this.quotes.value() ?? []).filter((q) => isQuotePending(q.status)).slice(0, LIMIT)) + protected readonly openTickets = computed(() => (this.tickets.value() ?? []).filter((t) => isTicketOpen(t.status)).slice(0, LIMIT)) + + protected readonly orderStatus = orderStatus + protected readonly quoteStatus = QUOTE_STATUS + protected readonly ticketStatus = TICKET_STATUS + protected readonly date = formatDate + protected readonly euro = formatEuro +} diff --git a/apps/private/src/app/pages/customer/profile.html b/apps/private/src/app/pages/customer/profile.html new file mode 100644 index 0000000..f2159e8 --- /dev/null +++ b/apps/private/src/app/pages/customer/profile.html @@ -0,0 +1,175 @@ +

    Il mio account

    + +
    +

    Dati personali

    + + @if (detailsState.message(); as m) { +

    {{ m.text }}

    + } +
    +
    + + + @if (role() === 'company') { + + + } +
    + @if (role() === 'installer') { +

    I dati aziendali (ragione sociale, P.IVA, sede, certificazioni) sono nel profilo aziendale.

    + } + +
    +
    + +
    +

    Password

    + @if (passwordState.message(); as m) { +

    {{ m.text }}

    + } +
    +
    + + + +
    + +
    +
    + +
    +

    Privacy e comunicazioni

    + @if (consents.isLoading() && !consents.hasValue()) { + + } @else if (consents.error()) { + + } @else if (consents.value(); as c) { + @if (c.marketing) { +

    Ricevi le nostre comunicazioni commerciali (novità e offerte via email).

    + + } @else { +

    Non ricevi comunicazioni commerciali. Puoi iscriverti quando vuoi e revocare in qualsiasi momento.

    + + } + @if (consentError(); as e) { + + } +

    + Il trattamento dei dati per gestire account, ordini e richieste è descritto nell'informativa privacy. +

    + @if (c.items.length) { +
    + Registro dei consensi ({{ c.items.length }}) +
    + + + + + + + + + + + @for (item of c.items; track item.id) { + + + + + + + } + +
    TipoDato ilDoveStato
    {{ item.type === 'privacy' ? 'Privacy (v. ' + item.version + ')' : 'Marketing' }}{{ dateTime(item.acceptedAt) }}{{ sources[item.source ?? ''] ?? item.source ?? '—' }}{{ item.revokedAt ? 'Revocato il ' + dateTime(item.revokedAt) : 'Attivo' }}
    +
    +
    + } + } +
    + +@if (role() !== 'admin') { +
    +

    Cancellazione dell'account

    +

    + L'account viene disattivato subito: non potrai più accedere e chiuderemo tutte le sessioni. I tuoi dati verranno poi + cancellati, salvo quelli che la legge ci obbliga a conservare (es. dati fiscali degli ordini). +

    + @if (deletionState.message(); as m) { + + } +
    +
    + + +
    + +
    +
    + + +} diff --git a/apps/private/src/app/pages/customer/profile.ts b/apps/private/src/app/pages/customer/profile.ts new file mode 100644 index 0000000..6c8ae53 --- /dev/null +++ b/apps/private/src/app/pages/customer/profile.ts @@ -0,0 +1,268 @@ +import type { ConsentDto, UserDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { AuthStore, type SessionResponse } from '../../core/auth.store' +import { formatDateTime } from '../../core/format' +import { ROLE_LABELS } from '../../core/roles' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { Loading, LoadError } from '../../shared/state' +import { mustMatch, PASSWORD_MIN } from '../auth/auth-helpers' + +interface ConsentsResponse { + marketing: boolean + items: ConsentDto[] +} + +const CONSENT_SOURCES: Record = { + register: 'registrazione', + contact: 'modulo contatti', + showroom: 'prenotazione showroom', + configurator: 'configuratore', + checkout: 'checkout', + account: 'area riservata', +} + +/** Corpo di `PATCH /me`: campi aziendali solo per le aziende, stringhe vuote → null. */ +export function profilePatch(role: UserDto['role'], value: { firstName: string; lastName: string; companyName: string; vatNumber: string }) { + return { + firstName: value.firstName.trim(), + lastName: value.lastName.trim(), + ...(role === 'company' ? { companyName: value.companyName.trim(), vatNumber: value.vatNumber.trim() } : {}), + } +} + +/** + * Il mio account: dati anagrafici (campi aziendali per le aziende), cambio password, consensi (marketing + * revocabile, registro), richiesta di cancellazione dell'account. + */ +@Component({ + selector: 'app-profile', + imports: [ReactiveFormsModule, RouterLink, FormField, ConfirmDialog, Loading, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './profile.html', + styles: ` + .single { + grid-template-columns: 1fr; + max-width: 32rem; + } + .form-grid { + margin-bottom: var(--space-4); + } + .email { + margin-bottom: var(--space-4); + } + .consents { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + th, + td { + padding: var(--space-2); + border-bottom: 1px solid var(--color-line); + text-align: left; + } + } + .table-wrap { + overflow-x: auto; + } + details summary { + min-height: 44px; + display: flex; + align-items: center; + cursor: pointer; + } + .danger-zone { + border-color: var(--color-danger); + } + `, +}) +export default class Profile { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + protected readonly auth = inject(AuthStore) + protected readonly siteUrl = inject(APP_CONFIG).siteUrl + + protected readonly role = computed(() => this.auth.role() ?? 'customer') + protected readonly roleLabel = computed(() => ROLE_LABELS[this.role()]) + + // --- Dati personali --- + protected readonly details = new FormGroup({ + firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), + lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), + companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }), + vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }), + }) + protected readonly detailsState = this.formState() + + // --- Password --- + protected readonly password = new FormGroup({ + currentPassword: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + newPassword: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)], + }), + confirm: new FormControl('', { nonNullable: true }), + }) + protected readonly passwordState = this.formState() + + // --- Consensi --- + protected readonly consents = rxResource({ stream: () => this.api.get('/me/consents') }) + protected readonly consentBusy = signal(false) + protected readonly consentError = signal(null) + + // --- Cancellazione --- + protected readonly deletion = new FormGroup({ + password: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + reason: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(2000)] }), + }) + protected readonly deletionState = this.formState() + private readonly deleteDialog = viewChild('deleteDialog') + + protected readonly dateTime = formatDateTime + protected readonly sources = CONSENT_SOURCES + + constructor() { + inject(Title).setTitle('Il mio account | Area riservata ITI TECHNOLOGY') + mustMatch(this.password.controls.newPassword, this.password.controls.confirm) + + // Form precompilato con l'utente corrente (anche dopo un salvataggio) + effect(() => { + const user = this.auth.user() + if (!user) return + this.details.reset({ + firstName: user.firstName, + lastName: user.lastName, + companyName: user.companyName ?? '', + vatNumber: user.vatNumber ?? '', + }) + const { companyName, vatNumber } = this.details.controls + if (user.role === 'company') { + companyName.setValidators([Validators.required, Validators.maxLength(200)]) + vatNumber.setValidators([Validators.required, Validators.minLength(5), Validators.maxLength(20)]) + } else { + companyName.clearValidators() + vatNumber.clearValidators() + } + companyName.updateValueAndValidity() + vatNumber.updateValueAndValidity() + }) + } + + protected async saveDetails(): Promise { + const state = this.detailsState + state.start() + if (this.details.invalid) return state.stop() + try { + const user = await firstValueFrom(this.api.patch('/me', profilePatch(this.role(), this.details.getRawValue()))) + this.auth.setUser(user) + state.done('Dati aggiornati.') + } catch (err) { + const error = toApiError(err) + state.fail(error.code === 'VALIDATION_ERROR' && applyServerErrors(this.details, error) ? 'Controlla i campi evidenziati.' : error.message) + } + } + + protected async changePassword(): Promise { + const state = this.passwordState + state.start() + if (this.password.invalid) return state.stop() + try { + const { currentPassword, newPassword } = this.password.getRawValue() + const session = await firstValueFrom( + this.api.post('/auth/change-password', { currentPassword, newPassword }, { withCredentials: true }), + ) + this.auth.applySession(session) + this.password.reset() + state.done('Password aggiornata. Per sicurezza abbiamo chiuso le sessioni aperte sugli altri dispositivi.') + } catch (err) { + const error = toApiError(err) + if ((error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_CURRENT_PASSWORD') && applyServerErrors(this.password, error)) { + state.fail('Controlla i campi evidenziati.') + } else { + state.fail(error.code === 'RATE_LIMITED' ? 'Troppi tentativi: attendi un minuto e riprova.' : error.message) + } + } + } + + protected async setMarketing(enabled: boolean): Promise { + this.consentError.set(null) + this.consentBusy.set(true) + try { + const path = '/me/consents/marketing' + const res = await firstValueFrom(enabled ? this.api.post(path, null) : this.api.delete(path)) + this.consents.set(res) + } catch (err) { + this.consentError.set(toApiError(err).message) + } finally { + this.consentBusy.set(false) + } + } + + protected askDeletion(): void { + const state = this.deletionState + state.start() + if (this.deletion.invalid) return state.stop() + state.stop() + this.deleteDialog()?.open() + } + + protected async confirmDeletion(): Promise { + const state = this.deletionState + state.busy.set(true) + try { + const { password, reason } = this.deletion.getRawValue() + await firstValueFrom(this.api.post('/me/delete-account', { password, ...(reason.trim() ? { reason: reason.trim() } : {}) })) + this.deleteDialog()?.close() + // L'account è disattivato e le sessioni chiuse: si esce senza chiamare il logout + this.auth.forget() + await this.router.navigate(['/login'], { queryParams: { account: 'disattivato' }, replaceUrl: true }) + } catch (err) { + this.deleteDialog()?.close() + const error = toApiError(err) + if (error.code === 'INVALID_CREDENTIALS') { + this.deletion.controls.password.setErrors({ server: 'Password non corretta' }) + state.fail('Password non corretta: l’account non è stato disattivato.') + } else { + state.fail(error.message) + } + } finally { + state.busy.set(false) + } + } + + /** Stato di invio di un form: tentativo fatto, in corso, messaggio di esito. */ + private formState() { + const submitted = signal(false) + const busy = signal(false) + const message = signal<{ ok: boolean; text: string } | null>(null) + return { + submitted, + busy, + message, + start: () => { + submitted.set(true) + message.set(null) + busy.set(true) + }, + stop: () => busy.set(false), + done: (text: string) => { + busy.set(false) + submitted.set(false) + message.set({ ok: true, text }) + }, + fail: (text: string) => { + busy.set(false) + message.set({ ok: false, text }) + }, + } + } +} diff --git a/apps/private/src/app/pages/customer/quote-detail.ts b/apps/private/src/app/pages/customer/quote-detail.ts new file mode 100644 index 0000000..f7f0760 --- /dev/null +++ b/apps/private/src/app/pages/customer/quote-detail.ts @@ -0,0 +1,190 @@ +import type { QuoteDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiError, ApiService, toApiError } from '../../core/api.service' +import { DownloadService } from '../../core/download.service' +import { formatDate, formatDay, formatEuro } from '../../core/format' +import { isQuotePending, QUOTE_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Empty, Loading, LoadError } from '../../shared/state' + +type Decision = 'accept' | 'reject' + +/** + * Dettaglio preventivo: importo, validità, PDF e risposta. Accetta/rifiuta chiedono conferma e aggiornano + * lo stato in pagina con la risposta dell'API (nessun ricaricamento). + */ +@Component({ + selector: 'app-quote-detail', + imports: [RouterLink, Badge, ConfirmDialog, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Preventivi + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Preventivo non trovato

    + + } @else if (res.error()) { +

    Preventivo

    + + } @else if (res.value(); as q) { +
    +

    {{ q.title }}

    + +
    + + @if (notice(); as n) { +

    {{ n.text }}

    + } + +
    +
    +
    Numero
    +
    {{ q.number }}
    +
    Importo
    +
    {{ euro(q.amountCents) }} IVA inclusa
    + @if (q.sentAt) { +
    Ricevuto il
    +
    {{ date(q.sentAt) }}
    + } +
    Validità
    +
    {{ q.validUntil ? 'fino al ' + day(q.validUntil) : 'senza scadenza' }}
    +
    + +
    + @if (q.hasPdf) { + + } + @if (pending()) { + + + } +
    + @if (downloadError(); as message) { + + } +
    + + @switch (q.status) { + @case ('accepted') { +

    Hai accettato questo preventivo: ti contatteremo per organizzare i prossimi passi.

    + } + @case ('rejected') { +

    Hai rifiutato questo preventivo. Se hai cambiato idea o vuoi una proposta diversa, scrivici.

    + } + @case ('expired') { +

    Il preventivo è scaduto. Per una nuova proposta aggiornata scrivici.

    + } + } + + + } + `, + styles: ` + .actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-5); + } + `, +}) +export default class QuoteDetail { + private readonly api = inject(ApiService) + private readonly downloads = inject(DownloadService) + readonly id = input.required() + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/me/quotes/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => { + const err = this.res.error() + return err instanceof ApiError && (err.status === 404 || err.status === 400) + }) + protected readonly pending = computed(() => { + const q = this.res.value() + return q ? isQuotePending(q.status) : false + }) + + protected readonly decision = signal(null) + protected readonly sending = signal(false) + protected readonly notice = signal<{ ok: boolean; text: string } | null>(null) + protected readonly downloading = signal(false) + protected readonly downloadError = signal(null) + private readonly dialog = viewChild(ConfirmDialog) + + protected readonly labels = QUOTE_STATUS + protected readonly date = formatDate + protected readonly day = formatDay + protected readonly euro = formatEuro + + constructor() { + const title = inject(Title) + effect(() => { + const q = this.res.value() + title.setTitle(`${q ? `Preventivo ${q.number}` : 'Preventivo'} | Area riservata ITI TECHNOLOGY`) + }) + } + + protected ask(decision: Decision): void { + this.notice.set(null) + this.decision.set(decision) + this.dialog()?.open() + } + + protected async send(quote: QuoteDto): Promise { + const decision = this.decision() + if (!decision || this.sending()) return + this.sending.set(true) + try { + const updated = await firstValueFrom(this.api.post(`/me/quotes/${quote.id}/${decision}`, null)) + this.res.set(updated) + this.notice.set({ + ok: true, + text: decision === 'accept' ? 'Preventivo accettato. Grazie!' : 'Preventivo rifiutato.', + }) + } catch (err) { + const error = toApiError(err) + this.notice.set({ ok: false, text: error.message }) + // Stato cambiato altrove (scaduto, già risposto): si rilegge il preventivo + if (error.status === 409) this.res.reload() + } finally { + this.sending.set(false) + this.decision.set(null) + this.dialog()?.close() + } + } + + protected async downloadPdf(quote: QuoteDto): Promise { + this.downloadError.set(null) + this.downloading.set(true) + try { + await this.downloads.download(`/me/quotes/${quote.id}/pdf`, `Preventivo ${quote.number}.pdf`) + } catch (err) { + this.downloadError.set(toApiError(err).message) + } finally { + this.downloading.set(false) + } + } +} diff --git a/apps/private/src/app/pages/customer/quotes.ts b/apps/private/src/app/pages/customer/quotes.ts new file mode 100644 index 0000000..157609e --- /dev/null +++ b/apps/private/src/app/pages/customer/quotes.ts @@ -0,0 +1,87 @@ +import type { Paginated, QuoteDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { ApiService } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatDate, formatDay, formatEuro } from '../../core/format' +import { QUOTE_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' + +const PAGE_SIZE = 10 + +/** Preventivi ricevuti (le bozze dell'ufficio non sono visibili). */ +@Component({ + selector: 'app-quotes', + imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    Preventivi

    + Richiedi un preventivo +
    + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { + + + } @else { + +

    Puoi chiederne uno con il configuratore o dalla pagina contatti del sito.

    +
    + } + } + `, +}) +export default class Quotes { + private readonly api = inject(ApiService) + protected readonly configuratorUrl = `${inject(APP_CONFIG).siteUrl}/configuratore` + readonly pagina = input() + + protected readonly pageSize = PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly res = rxResource({ + params: () => this.page(), + stream: ({ params }) => + this.api.get>('/me/quotes', { query: { limit: PAGE_SIZE, offset: (params - 1) * PAGE_SIZE } }), + }) + + protected readonly labels = QUOTE_STATUS + protected readonly date = formatDate + protected readonly day = formatDay + protected readonly euro = formatEuro + + constructor() { + inject(Title).setTitle('Preventivi | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/pages/customer/ticket-detail.ts b/apps/private/src/app/pages/customer/ticket-detail.ts new file mode 100644 index 0000000..7a4d577 --- /dev/null +++ b/apps/private/src/app/pages/customer/ticket-detail.ts @@ -0,0 +1,162 @@ +import type { TicketDetailDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiError, ApiService, toApiError } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { TICKET_PRIORITY, TICKET_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { FormField } from '../../shared/form-field' +import { Empty, Loading, LoadError } from '../../shared/state' + +/** + * Conversazione di un ticket. La risposta del cliente rimette il ticket "aperto" (anche se era chiuso) e + * aggiorna la pagina con il dettaglio restituito dall'API. + */ +@Component({ + selector: 'app-ticket-detail', + imports: [ReactiveFormsModule, RouterLink, Badge, FormField, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Assistenza + + @if (res.isLoading() && !res.hasValue()) { + + } @else if (notFound()) { +

    Richiesta non trovata

    + + } @else if (res.error()) { +

    Richiesta di assistenza

    + + } @else if (res.value(); as t) { +
    +

    {{ t.subject }}

    + +
    +

    Aperta il {{ dateTime(t.createdAt) }} · priorità {{ priority[t.priority].toLowerCase() }}

    + @if (created) { +

    Richiesta inviata: ti avviseremo via email quando risponderemo.

    + } + +
      + @for (m of t.messages; track m.id) { +
    1. +

      + {{ m.fromStaff ? 'Assistenza ITI TECHNOLOGY' : 'Tu' }} + {{ dateTime(m.createdAt) }} +

      +

      {{ m.body }}

      +
    2. + } +
    + +
    + + @if (error(); as e) { + + } + + + } + `, + styles: ` + .thread { + display: grid; + gap: var(--space-3); + margin: var(--space-5) 0; + padding: 0; + list-style: none; + + li { + max-width: 48rem; + padding: var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + background: var(--color-surface); + } + li.staff { + border-color: var(--color-primary); + background: var(--color-primary-soft); + } + } + .who { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + .body { + margin: 0; + white-space: pre-line; + overflow-wrap: anywhere; + } + app-form-field { + margin-bottom: var(--space-4); + } + `, +}) +export default class TicketDetail { + private readonly api = inject(ApiService) + readonly id = input.required() + /** Arrivati dalla creazione del ticket (stato della navigazione). */ + protected readonly created = Boolean((inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created) + + protected readonly res = rxResource({ + params: () => this.id(), + stream: ({ params }) => this.api.get(`/me/tickets/${encodeURIComponent(params)}`), + }) + protected readonly notFound = computed(() => { + const err = this.res.error() + return err instanceof ApiError && (err.status === 404 || err.status === 400) + }) + + protected readonly message = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(5000)] }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + + protected readonly labels = TICKET_STATUS + protected readonly priority = TICKET_PRIORITY + protected readonly dateTime = formatDateTime + + constructor() { + const title = inject(Title) + effect(() => { + const t = this.res.value() + title.setTitle(`${t ? t.subject : 'Richiesta di assistenza'} | Area riservata ITI TECHNOLOGY`) + }) + } + + protected async reply(ticket: TicketDetailDto): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.message.invalid || this.message.value.trim() === '' || this.busy()) { + this.message.setErrors({ required: true }) + return + } + this.busy.set(true) + try { + const updated = await firstValueFrom( + this.api.post(`/me/tickets/${ticket.id}/messages`, { body: this.message.value.trim() }), + ) + this.res.set(updated) + this.message.reset() + this.submitted.set(false) + } catch (err) { + const error = toApiError(err) + this.error.set(error.code === 'RATE_LIMITED' ? 'Troppi messaggi in poco tempo: riprova più tardi.' : error.message) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/customer/ticket-new.ts b/apps/private/src/app/pages/customer/ticket-new.ts new file mode 100644 index 0000000..83b35a8 --- /dev/null +++ b/apps/private/src/app/pages/customer/ticket-new.ts @@ -0,0 +1,97 @@ +import type { TicketDetailDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { Router, RouterLink } from '@angular/router' +import { firstValueFrom } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' + +/** Nuova richiesta di assistenza. `?ordine=12` precompila l'oggetto (link dal dettaglio ordine). */ +@Component({ + selector: 'app-ticket-new', + imports: [ReactiveFormsModule, RouterLink, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + ← Assistenza +

    Nuova richiesta di assistenza

    +

    Descrivi il problema o la domanda: ti risponderemo qui e ti avviseremo via email.

    + + @if (error(); as message) { + + } +
    +
    + +
    + + +
    + +
    + +
    + `, + styles: ` + .single { + grid-template-columns: 1fr; + margin-bottom: var(--space-5); + } + `, +}) +export default class TicketNew { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + readonly ordine = input() + + protected readonly form = new FormGroup({ + subject: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3), Validators.maxLength(200)] }), + priority: new FormControl<'low' | 'normal' | 'high'>('normal', { nonNullable: true }), + body: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(5000)] }), + }) + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly error = signal(null) + + constructor() { + inject(Title).setTitle('Nuova richiesta | Area riservata ITI TECHNOLOGY') + effect(() => { + const order = Number(this.ordine()) + if (Number.isInteger(order) && order > 0 && this.form.controls.subject.value === '') { + this.form.controls.subject.setValue(`Ordine n. ${order}`) + } + }) + } + + protected async submit(): Promise { + this.submitted.set(true) + this.error.set(null) + if (this.form.invalid || this.busy()) return + this.busy.set(true) + try { + const { subject, priority, body } = this.form.getRawValue() + const created = await firstValueFrom(this.api.post('/me/tickets', { subject: subject.trim(), priority, body: body.trim() })) + await this.router.navigate(['/assistenza', created.id], { replaceUrl: true, state: { created: true } }) + } catch (err) { + const error = toApiError(err) + if (error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error)) this.error.set('Controlla i campi evidenziati.') + else if (error.code === 'RATE_LIMITED') this.error.set('Hai aperto molte richieste nell’ultima ora: riprova più tardi.') + else this.error.set(error.message) + } finally { + this.busy.set(false) + } + } +} diff --git a/apps/private/src/app/pages/customer/tickets.ts b/apps/private/src/app/pages/customer/tickets.ts new file mode 100644 index 0000000..03a5604 --- /dev/null +++ b/apps/private/src/app/pages/customer/tickets.ts @@ -0,0 +1,125 @@ +import type { Paginated, TicketDto, TicketStatus } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { ApiService } from '../../core/api.service' +import { formatDateTime } from '../../core/format' +import { TICKET_PRIORITY, TICKET_STATUS } from '../../core/labels' +import { Badge } from '../../shared/badge' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' + +const PAGE_SIZE = 10 +const FILTERS: { value: TicketStatus | null; label: string }[] = [ + { value: null, label: 'Tutte' }, + { value: 'waiting_customer', label: 'In attesa di tua risposta' }, + { value: 'open', label: 'Aperte' }, + { value: 'in_progress', label: 'In lavorazione' }, + { value: 'closed', label: 'Chiuse' }, +] + +/** Richieste di assistenza (ticket), dalla più recente; filtro per stato con `?stato=`. */ +@Component({ + selector: 'app-tickets', + imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    Assistenza

    + Nuova richiesta +
    + + + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { + + + } @else { + + @if (!status()) { + Apri una richiesta + } + + } + } + `, + styles: ` + .filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-4); + + a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-ink); + text-decoration: none; + + &[aria-current='page'] { + border-color: var(--color-primary); + background: var(--color-primary-soft); + color: var(--color-primary-strong); + font-weight: var(--font-weight-semibold); + } + } + } + `, +}) +export default class Tickets { + private readonly api = inject(ApiService) + readonly pagina = input() + readonly stato = input() + + protected readonly filters = FILTERS + protected readonly pageSize = PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly status = computed(() => FILTERS.find((f) => f.value === this.stato())?.value ?? null) + protected readonly res = rxResource({ + params: () => ({ page: this.page(), status: this.status() }), + stream: ({ params }) => + this.api.get>('/me/tickets', { + query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, status: params.status }, + }), + }) + + protected readonly labels = TICKET_STATUS + protected readonly priority = TICKET_PRIORITY + protected readonly dateTime = formatDateTime + + constructor() { + inject(Title).setTitle('Assistenza | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/pages/installer/approval-gate.ts b/apps/private/src/app/pages/installer/approval-gate.ts new file mode 100644 index 0000000..6bb6dc8 --- /dev/null +++ b/apps/private/src/app/pages/installer/approval-gate.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core' +import { RouterLink } from '@angular/router' +import { InstallerProfileStore } from '../../core/installer-profile.store' +import { Loading, LoadError } from '../../shared/state' + +/** + * Avviso al posto dei contenuti riservati agli installatori approvati (listino, documentazione tecnica): stato + * della richiesta e invito a completare il profilo. Le pagine lo usano così, e caricano i dati solo da approvati + * (nessun ``: il contenuto proiettato verrebbe istanziato comunque): + * `@if (store.approved()) { … } @else { }` + */ +@Component({ + selector: 'app-approval-gate', + imports: [RouterLink, Loading, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @switch (store.state()) { + @case (null) { + @if (store.resource.error()) { + + } @else { + + } + } + @case ('rejected') { +
    +

    {{ what() }} non è disponibile. La tua richiesta come installatore non è stata approvata.

    +

    Per chiarimenti scrivi all'assistenza.

    +
    + } + @default { +
    +

    + {{ what() }} sarà disponibile dopo l'approvazione del tuo profilo installatore: la stiamo + valutando. +

    + @if (store.missing().length) { +

    Completa il profilo per velocizzare la verifica (mancano: {{ store.missing().join(', ') }}).

    + } + {{ cta() }} +
    + } + } + `, + styles: ` + .notice { + border-left: 4px solid var(--color-accent); + p:last-of-type { + margin-bottom: var(--space-4); + } + } + `, +}) +export class ApprovalGate { + protected readonly store = inject(InstallerProfileStore) + /** Nome della sezione ("Il listino riservato", "La documentazione tecnica"). */ + readonly what = input.required() + protected readonly cta = computed(() => (this.store.state() === 'missing' ? 'Compila il profilo' : 'Completa il profilo')) +} diff --git a/apps/private/src/app/pages/installer/installer-home.ts b/apps/private/src/app/pages/installer/installer-home.ts new file mode 100644 index 0000000..0cd36d6 --- /dev/null +++ b/apps/private/src/app/pages/installer/installer-home.ts @@ -0,0 +1,179 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { AuthStore } from '../../core/auth.store' +import { formatDate } from '../../core/format' +import { InstallerProfileStore } from '../../core/installer-profile.store' +import { Loading, LoadError } from '../../shared/state' +import { CustomerOverview } from '../customer/overview' + +/** + * Home dell'installatore. + * - In attesa (o profilo da compilare): pagina di attesa con i passaggi della richiesta e i dati mancanti. + * - Rifiutato: esito e contatto con l'assistenza. + * - Approvato: accesso rapido a listino, documentazione e profilo, più il riepilogo dell'area clienti + * (ordini, preventivi, assistenza restano disponibili anche all'installatore). + */ +@Component({ + selector: 'app-installer-home', + imports: [RouterLink, CustomerOverview, Loading, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Ciao {{ auth.user()?.firstName }}

    + + @switch (store.state()) { + @case (null) { + @if (store.resource.error()) { + + } @else { + + } + } + @case ('approved') { +

    {{ activeSince() }}

    + + + } + @case ('rejected') { +
    +

    Richiesta non approvata

    +

    + Non abbiamo potuto approvare il tuo profilo installatore: listino riservato e documentazione tecnica non sono + disponibili. Puoi comunque acquistare dallo shop e usare ordini, preventivi e assistenza. +

    + Scrivi all'assistenza +
    + } + @default { +
    +

    La tua richiesta è in valutazione

    +

    Verifichiamo i dati di ogni installatore prima di dare accesso al listino riservato e alla documentazione tecnica.

    +
      +
    1. Registrazione come installatore
    2. +
    3. + + + Profilo aziendale completo + @if (!complete()) { + — mancano: {{ store.missing().join(', ') }} + } + +
    4. +
    5. Verifica da parte del nostro staff (riceverai un'email)
    6. +
    7. Accesso a listino riservato e documentazione tecnica
    8. +
    + + {{ complete() ? 'Rivedi il profilo' : 'Completa il profilo' }} + +
    +

    Nel frattempo puoi acquistare dallo shop ai prezzi di listino e usare ordini, preventivi e assistenza.

    + } + } + `, + styles: ` + .shortcuts { + display: grid; + gap: var(--space-4); + margin: 0 0 var(--space-6); + padding: 0; + list-style: none; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } + } + .shortcut { + display: flex; + flex-direction: column; + gap: var(--space-1); + height: 100%; + color: var(--color-ink); + text-decoration: none; + + strong { + color: var(--color-primary); + } + &:hover { + border-color: var(--color-primary); + } + } + .waiting { + border-left: 4px solid var(--color-accent); + } + .steps { + display: grid; + gap: var(--space-3); + margin: var(--space-4) 0 var(--space-5); + padding: 0; + list-style: none; + + li { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--color-ink-muted); + } + li.done, + li.current { + color: var(--color-ink); + } + li.current { + font-weight: var(--font-weight-semibold); + } + } + .mark { + flex: none; + display: inline-grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: var(--color-surface-alt); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + } + .done .mark { + background: var(--color-secondary-soft); + color: var(--color-success); + } + .current .mark { + background: var(--color-accent-soft); + color: var(--color-warning); + } + .after { + margin-top: var(--space-4); + } + `, +}) +export default class InstallerHome { + protected readonly auth = inject(AuthStore) + protected readonly store = inject(InstallerProfileStore) + protected readonly complete = computed(() => this.store.missing().length === 0) + protected readonly activeSince = computed(() => { + const at = this.store.profile()?.approvedAt + return at ? `Il tuo profilo installatore è attivo dal ${formatDate(at)}.` : 'Il tuo profilo installatore è attivo.' + }) + + constructor() { + inject(Title).setTitle('Area installatore | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/pages/installer/installer-profile.html b/apps/private/src/app/pages/installer/installer-profile.html new file mode 100644 index 0000000..89ceccb --- /dev/null +++ b/apps/private/src/app/pages/installer/installer-profile.html @@ -0,0 +1,132 @@ +

    Profilo aziendale

    + +@if (store.state() === null) { + @if (store.resource.error()) { + + } @else { + + } +} @else { +

    + {{ statusText() }} + @if (store.profile()?.approvedAt; as at) { + (dal {{ date(at) }}) + } +

    + + @if (message(); as m) { +

    {{ m.text }}

    + } + +
    +
    +

    Dati aziendali

    +
    + + + + +
    +
    + +
    +

    Zona di intervento

    +

    + Usiamo la posizione della sede e il raggio di servizio per proporti le richieste dei clienti della tua zona. +

    +
    + +
    + + +
    + @if (coordError() && (submitted() || form.controls.lng.touched)) { + + } + +
    + @if (!mapOpen()) { + + } + @if (location()) { + + } +
    + @if (mapOpen()) { + @defer { +

    Clicca sulla mappa per impostare la sede; il cerchio mostra la zona coperta dal raggio di servizio.

    + + } @loading (minimum 200ms) { + + } + } @else { +

    La mappa usa OpenStreetMap e si carica solo quando la apri.

    + } +
    + +
    +

    Certificazioni

    +

    Abilitazioni e qualifiche (es. F-GAS, FER, patentino frigorista). Massimo {{ maxCertifications }}.

    + @if (certifications().length) { +
      + @for (c of certifications(); track $index) { +
    • + {{ c.value }} + +
    • + } +
    + } + @if (certifications().length < maxCertifications) { +
    + + + +
    + } +
    + + + Torna all'area installatore +
    +} diff --git a/apps/private/src/app/pages/installer/installer-profile.scss b/apps/private/src/app/pages/installer/installer-profile.scss new file mode 100644 index 0000000..13215b4 --- /dev/null +++ b/apps/private/src/app/pages/installer/installer-profile.scss @@ -0,0 +1,81 @@ +.status { + padding: var(--space-3) var(--space-4); + border-left: 4px solid var(--color-accent); + border-radius: var(--radius-md); + background: var(--color-accent-soft); + + &[data-state='approved'] { + border-color: var(--color-success); + background: var(--color-secondary-soft); + } + &[data-state='rejected'] { + border-color: var(--color-danger); + background: var(--color-surface); + } +} + +.form-grid { + margin-bottom: var(--space-3); +} + +.map-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); + margin: var(--space-3) 0; +} + +.map-note { + margin: 0; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; + + li { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding-left: var(--space-3); + border-radius: var(--radius-pill); + background: var(--color-primary-soft); + color: var(--color-primary-strong); + } + button { + min-width: 36px; + min-height: 36px; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + font-size: var(--font-size-lg); + cursor: pointer; + } +} + +.add { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + + input { + flex: 1 1 16rem; + min-height: 44px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-ink-muted); + border-radius: var(--radius-md); + } +} + +.cancel { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-left: var(--space-4); +} diff --git a/apps/private/src/app/pages/installer/installer-profile.ts b/apps/private/src/app/pages/installer/installer-profile.ts new file mode 100644 index 0000000..dd59ba8 --- /dev/null +++ b/apps/private/src/app/pages/installer/installer-profile.ts @@ -0,0 +1,164 @@ +import type { InstallerProfileDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { toApiError } from '../../core/api.service' +import { formatDate } from '../../core/format' +import { InstallerProfileStore } from '../../core/installer-profile.store' +import { applyServerErrors } from '../../shared/form-errors' +import { FormField } from '../../shared/form-field' +import { LocationPicker } from '../../shared/location-picker' +import { Loading, LoadError } from '../../shared/state' +import { + coordinatesError, + formatCoordinate, + MAX_CERTIFICATIONS, + parseDecimal, + RADIUS_MAX, + RADIUS_MIN, + radiusValidator, + toProfileInput, +} from './profile-form' + +const STATUS_TEXT = { + missing: 'Profilo da compilare: compilalo per inviarci la richiesta di approvazione.', + pending: 'Richiesta in valutazione: ti avviseremo via email quando il profilo sarà approvato.', + approved: 'Profilo approvato: hai accesso al listino riservato e alla documentazione tecnica.', + rejected: 'Richiesta non approvata. Per chiarimenti scrivi all’assistenza.', +} as const + +/** + * Profilo aziendale dell'installatore: dati, certificazioni, sede su mappa con raggio di servizio. Il salvataggio + * (`PUT /installer/profile`) non cambia lo stato di approvazione, che decide l'admin. Posizione e raggio sono + * quelli usati dalla ricerca degli installatori vicini (`/admin/installers/nearby`, PostGIS). + */ +@Component({ + selector: 'app-installer-profile', + imports: [ReactiveFormsModule, RouterLink, FormField, LocationPicker, Loading, LoadError], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './installer-profile.html', + styleUrl: './installer-profile.scss', +}) +export default class InstallerProfile { + protected readonly store = inject(InstallerProfileStore) + + protected readonly form = new FormGroup({ + businessName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(2), Validators.maxLength(200)] }), + vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(20)] }), + phone: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(30)] }), + address: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(300)] }), + serviceRadiusKm: new FormControl('50', { nonNullable: true, validators: [radiusValidator] }), + lat: new FormControl('', { nonNullable: true }), + lng: new FormControl('', { nonNullable: true }), + certifications: new FormArray>([]), + }) + protected readonly newCertification = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(100)] }) + + protected readonly submitted = signal(false) + protected readonly busy = signal(false) + protected readonly message = signal<{ ok: boolean; text: string } | null>(null) + protected readonly mapOpen = signal(false) + + private readonly values = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() }) + /** Posizione corrente dai campi (per la mappa), null se assente o non valida. */ + protected readonly location = computed(() => { + const v = this.values() + const lat = parseDecimal(v.lat ?? '') + const lng = parseDecimal(v.lng ?? '') + return lat !== null && lng !== null && !coordinatesError(v.lat ?? '', v.lng ?? '') ? { lat, lng } : null + }) + protected readonly radius = computed(() => { + const n = Number(this.values().serviceRadiusKm) + return Number.isInteger(n) && n >= RADIUS_MIN && n <= RADIUS_MAX ? n : 50 + }) + protected readonly coordError = computed(() => { + const v = this.values() + return coordinatesError(v.lat ?? '', v.lng ?? '') + }) + + protected readonly statusText = computed(() => { + const state = this.store.state() + return state ? STATUS_TEXT[state] : '' + }) + protected readonly radiusMin = RADIUS_MIN + protected readonly radiusMax = RADIUS_MAX + protected readonly maxCertifications = MAX_CERTIFICATIONS + protected readonly date = formatDate + + constructor() { + inject(Title).setTitle('Profilo aziendale | Area riservata ITI TECHNOLOGY') + // Form compilato dal profilo salvato (una volta caricato e dopo ogni salvataggio) + effect(() => { + const p = this.store.profile() + if (p !== undefined) this.fill(p) + }) + } + + protected certifications(): FormControl[] { + return this.form.controls.certifications.controls + } + + protected addCertification(): void { + const value = this.newCertification.value.trim() + const list = this.form.controls.certifications + if (!value || this.newCertification.invalid || list.length >= MAX_CERTIFICATIONS) return + if (!list.value.some((c) => c.toLowerCase() === value.toLowerCase())) list.push(new FormControl(value, { nonNullable: true })) + this.newCertification.reset() + } + + protected removeCertification(index: number): void { + this.form.controls.certifications.removeAt(index) + } + + protected setLocation(point: { lat: number; lng: number }): void { + this.form.patchValue({ lat: formatCoordinate(point.lat), lng: formatCoordinate(point.lng) }) + this.form.markAsDirty() + } + + protected clearLocation(): void { + this.form.patchValue({ lat: '', lng: '' }) + this.form.markAsDirty() + } + + protected async save(): Promise { + this.submitted.set(true) + this.message.set(null) + if (this.form.invalid || this.coordError() || this.busy()) { + this.message.set({ ok: false, text: 'Controlla i campi evidenziati.' }) + return + } + this.busy.set(true) + try { + const saved = await this.store.save(toProfileInput(this.form.getRawValue())) + this.submitted.set(false) + this.message.set({ + ok: true, + text: saved.status === 'approved' ? 'Profilo salvato.' : 'Profilo salvato: lo valuteremo al più presto.', + }) + } catch (err) { + const error = toApiError(err) + const onFields = error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error, { location: 'lat' }) + this.message.set({ ok: false, text: onFields ? 'Controlla i campi evidenziati.' : error.message }) + } finally { + this.busy.set(false) + } + } + + private fill(p: InstallerProfileDto | null): void { + const list = this.form.controls.certifications + list.clear() + for (const c of p?.certifications ?? []) list.push(new FormControl(c, { nonNullable: true })) + this.form.patchValue({ + businessName: p?.businessName ?? '', + vatNumber: p?.vatNumber ?? '', + phone: p?.phone ?? '', + address: p?.address ?? '', + serviceRadiusKm: String(p?.serviceRadiusKm ?? 50), + lat: p?.location ? formatCoordinate(p.location.lat) : '', + lng: p?.location ? formatCoordinate(p.location.lng) : '', + }) + this.form.markAsPristine() + } +} diff --git a/apps/private/src/app/pages/installer/installer.spec.ts b/apps/private/src/app/pages/installer/installer.spec.ts new file mode 100644 index 0000000..1791829 --- /dev/null +++ b/apps/private/src/app/pages/installer/installer.spec.ts @@ -0,0 +1,205 @@ +import type { DocumentDto, InstallerProfileDto, 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 { provideRouter } from '@angular/router' +import { APP_CONFIG } from '../../core/app-config' +import { authInterceptor } from '../../core/auth.interceptor' +import { AuthStore } from '../../core/auth.store' +import { missingProfileFields } from '../../core/installer-profile.store' +import PriceList, { discountPercent } from './price-list' +import { coordinatesError, formatCoordinate, parseDecimal, toProfileInput } from './profile-form' +import TechnicalDocs, { categoriesOf } from './technical-docs' + +const API = 'http://api.test' +const tick = () => new Promise((resolve) => setTimeout(resolve)) + +const profile = (over: Partial = {}): InstallerProfileDto => ({ + userId: 'u-inst', + businessName: 'Rossi Impianti', + vatNumber: '12345678903', + phone: '02 1234567', + address: 'Via Roma 1, 20900 Monza (MB)', + location: { lat: 45.5845, lng: 9.2744 }, + serviceRadiusKm: 50, + certifications: ['F-GAS'], + status: 'approved', + approvedAt: '2026-10-01T10:00:00.000Z', + ...over, +}) + +describe('area installatori: funzioni', () => { + it('dati mancanti del profilo', () => { + expect(missingProfileFields(profile())).toEqual([]) + expect(missingProfileFields(profile({ phone: null, location: null, certifications: [] }))).toEqual([ + 'telefono', + 'posizione sulla mappa', + 'certificazioni', + ]) + expect(missingProfileFields(null)).toContain('ragione sociale') + }) + + it('coordinate: formato italiano, entrambe o nessuna, solo in Italia', () => { + expect(parseDecimal('45,5845')).toBe(45.5845) + expect(parseDecimal('9.27')).toBe(9.27) + expect(parseDecimal('abc')).toBeNull() + expect(coordinatesError('', '')).toBeNull() + expect(coordinatesError('45,58', '9,27')).toBeNull() + expect(coordinatesError('45,58', '')).toMatch(/latitudine e longitudine/) + expect(coordinatesError('48,85', '2,35')).toBe('La posizione deve essere in Italia') // Parigi + expect(formatCoordinate(45.58451)).toBe('45,58451') + }) + + it('corpo di PUT /installer/profile', () => { + expect( + toProfileInput({ + businessName: ' Rossi Impianti ', + vatNumber: '', + phone: ' 02 1234567 ', + address: '', + serviceRadiusKm: '40', + lat: '45,5845', + lng: '9,2744', + certifications: ['F-GAS', ' F-GAS ', '', 'FER'], + }), + ).toEqual({ + businessName: 'Rossi Impianti', + vatNumber: null, + phone: '02 1234567', + address: null, + location: { lat: 45.5845, lng: 9.2744 }, + serviceRadiusKm: 40, + certifications: ['F-GAS', 'FER'], + }) + expect(toProfileInput({ businessName: 'X', vatNumber: '', phone: '', address: '', serviceRadiusKm: '1', lat: '', lng: '', certifications: [] }).location).toBeNull() + }) + + it('sconto e categorie', () => { + expect(discountPercent(465000, 549000)).toBe(15) + expect(discountPercent(549000, 549000)).toBeNull() + expect(discountPercent(null, 549000)).toBeNull() + const doc = (category: string | null) => ({ category }) as DocumentDto + expect(categoriesOf([doc('Manuali'), doc('CAD'), doc(null), doc('Manuali'), doc(' ')])).toEqual(['CAD', 'Manuali']) + }) +}) + +describe('listino e documentazione: solo per installatori approvati', () => { + let http: HttpTestingController + + const user: UserDto = { + id: 'u-inst', + email: 'inst@example.com', + role: 'installer', + firstName: 'Ada', + lastName: 'Rossi', + companyName: 'Rossi Impianti', + vatNumber: null, + emailVerified: true, + createdAt: '2026-01-01T00:00:00.000Z', + } + + async function setup(status: InstallerProfileDto['status']) { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + provideRouter([]), + { provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: 'http://sito.test', medusaAdminUrl: 'http://medusa.test/app' } }, + ], + }) + http = TestBed.inject(HttpTestingController) + TestBed.inject(AuthStore).applySession({ accessToken: 'tok', expiresIn: 900, user }) + } + + async function flushProfile(status: InstallerProfileDto['status']) { + await tick() + http.expectOne(`${API}/installer/profile`).flush(profile({ status, approvedAt: status === 'approved' ? '2026-10-01T10:00:00.000Z' : null })) + await tick() + } + + afterEach(() => http.verify()) + + it('in attesa: niente richiesta del listino, avviso con invito a completare il profilo', async () => { + await setup('pending') + const fixture = TestBed.createComponent(PriceList) + fixture.detectChanges() + await flushProfile('pending') + await fixture.whenStable() + http.expectNone((req) => req.url.startsWith(`${API}/shop/products`)) + const text = (fixture.nativeElement as HTMLElement).textContent ?? '' + expect(text).toContain("sarà disponibile dopo l'approvazione") + expect(text).not.toContain('Tuo prezzo') + }) + + it('in attesa: niente richiesta dei documenti tecnici', async () => { + await setup('pending') + const fixture = TestBed.createComponent(TechnicalDocs) + fixture.detectChanges() + await flushProfile('pending') + await fixture.whenStable() + http.expectNone((req) => req.url.startsWith(`${API}/documents`)) + expect((fixture.nativeElement as HTMLElement).textContent).toContain('La documentazione tecnica sarà disponibile') + }) + + it('approvato: listino con il token, prezzo riservato e di listino a confronto', async () => { + await setup('approved') + const fixture = TestBed.createComponent(PriceList) + fixture.detectChanges() + await flushProfile('approved') + await tick() + const req = http.expectOne((r) => r.url === `${API}/shop/products`) + expect(req.request.headers.get('Authorization')).toBe('Bearer tok') + req.flush({ + items: [ + { + handle: 'pompa-di-calore-aria-acqua-8kw', + title: 'Pompa di calore 8 kW', + subtitle: null, + thumbnail: null, + fromPrice: 465000, + fromOriginalPrice: 549000, + priceType: 'installer', + categories: [], + }, + ], + total: 1, + limit: 20, + offset: 0, + }) + await fixture.whenStable() + const text = ((fixture.nativeElement as HTMLElement).textContent ?? '').replace(/\s+/g, ' ') + expect(text).toContain('5.490 €') + expect(text).toContain('4.650 €') + expect(text).toContain('−15%') + const link = (fixture.nativeElement as HTMLElement).querySelector('td a') + expect(link?.href).toBe('http://sito.test/shop/pompa-di-calore-aria-acqua-8kw') + }) + + it('approvato: solo documenti installer, filtro per categoria', async () => { + await setup('approved') + const fixture = TestBed.createComponent(TechnicalDocs) + fixture.componentRef.setInput('categoria', 'CAD') + fixture.detectChanges() + await flushProfile('approved') + await tick() + const doc = (id: string, category: string, visibility: DocumentDto['visibility']): DocumentDto => ({ + id, + title: `Doc ${id}`, + category, + mimeType: 'application/pdf', + sizeBytes: 100, + visibility, + productRef: null, + createdAt: '2026-10-01T10:00:00.000Z', + }) + http.expectOne((r) => r.url === `${API}/documents`).flush({ + items: [doc('1', 'Manuali', 'installer'), doc('2', 'CAD', 'installer'), doc('3', 'CAD', 'customer')], + total: 3, + limit: 100, + offset: 0, + }) + await fixture.whenStable() + const titles = [...(fixture.nativeElement as HTMLElement).querySelectorAll('.item__title')].map((e) => e.textContent?.trim()) + expect(titles).toEqual(['Doc 2']) + }) +}) diff --git a/apps/private/src/app/pages/installer/price-list.ts b/apps/private/src/app/pages/installer/price-list.ts new file mode 100644 index 0000000..ac45fa8 --- /dev/null +++ b/apps/private/src/app/pages/installer/price-list.ts @@ -0,0 +1,228 @@ +import type { Paginated, ShopProductSummaryDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { FormControl, ReactiveFormsModule } from '@angular/forms' +import { Title } from '@angular/platform-browser' +import { Router } from '@angular/router' +import { ApiService } from '../../core/api.service' +import { APP_CONFIG } from '../../core/app-config' +import { formatEuro } from '../../core/format' +import { InstallerProfileStore } from '../../core/installer-profile.store' +import { Pager, pageFromQuery } from '../../shared/pager' +import { Empty, Loading, LoadError } from '../../shared/state' +import { ApprovalGate } from './approval-gate' + +const PAGE_SIZE = 20 + +/** Sconto percentuale del prezzo riservato sul listino pubblico (arrotondato), null se non c'è sconto. */ +export function discountPercent(price: number | null, original: number | null): number | null { + if (price === null || original === null || original <= 0 || price >= original) return null + return Math.round(((original - price) / original) * 100) +} + +/** + * Listino riservato: prodotti dello shop con prezzo installatore e prezzo di listino a confronto + * (`GET /shop/products` con il token: l'API applica il listino solo agli installatori approvati). Solo per + * installatori approvati; si ordina dallo shop del sito, dove il carrello dell'account ha già i prezzi riservati. + */ +@Component({ + selector: 'app-price-list', + imports: [ReactiveFormsModule, Pager, Loading, Empty, LoadError, ApprovalGate], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    Listino riservato

    + @if (store.approved()) { + Vai allo shop + } +
    + + @if (store.approved()) { +

    Prezzi IVA inclusa. Accedendo allo shop con questo account, il carrello applica già i prezzi riservati.

    + + + + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (res.value(); as data) { + @if (data.items.length) { +
    + + + + + + + + + + + + + @for (p of data.items; track p.handle) { + + + + + + + + } + +
    Prodotti con prezzo riservato e prezzo di listino
    ProdottoListinoTuo prezzoScontoAzioni
    + + @if (p.thumbnail) { + + } + + {{ p.title }} + @if (p.subtitle) { + {{ p.subtitle }} + } + + + + {{ p.fromOriginalPrice === null ? '—' : euro(p.fromOriginalPrice) }} + + @if (p.fromPrice === null) { + — + } @else { + {{ euro(p.fromPrice) }} + @if (p.priceType !== 'installer') { + (listino) + } + } + + @if (discount(p); as d) { + −{{ d }}% + } @else { + — + } + Ordina {{ p.title }}
    +
    +

    Per i prodotti con più varianti è indicato il prezzo più basso; il dettaglio è nella scheda dello shop.

    + + } @else { + + } + } + } @else { + + } + `, + styles: ` + .search { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-4); + + input { + flex: 1 1 16rem; + min-height: 44px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-ink-muted); + border-radius: var(--radius-md); + } + } + .table-wrap { + overflow-x: auto; + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + background: var(--color-surface); + } + table { + width: 100%; + border-collapse: collapse; + } + th, + td { + padding: var(--space-3); + border-bottom: 1px solid var(--color-line); + text-align: left; + vertical-align: middle; + } + thead th { + font-size: var(--font-size-sm); + color: var(--color-ink-muted); + } + tbody th { + font-weight: var(--font-weight-semibold); + } + .product { + display: flex; + align-items: center; + gap: var(--space-3); + + img { + flex: none; + width: 40px; + height: 40px; + object-fit: cover; + border-radius: var(--radius-sm); + } + .meta { + display: block; + font-weight: var(--font-weight-regular); + } + } + .num { + text-align: right; + white-space: nowrap; + } + .strike { + text-decoration: line-through; + color: var(--color-ink-muted); + } + .price { + font-weight: var(--font-weight-bold); + } + td a { + display: inline-flex; + align-items: center; + min-height: 44px; + } + `, +}) +export default class PriceList { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + protected readonly store = inject(InstallerProfileStore) + protected readonly shopUrl = `${inject(APP_CONFIG).siteUrl}/shop` + readonly pagina = input() + readonly q = input() + + protected readonly pageSize = PAGE_SIZE + protected readonly page = computed(() => pageFromQuery(this.pagina())) + protected readonly query = new FormControl('', { nonNullable: true }) + + /** Si carica solo da approvati: per gli altri l'API darebbe comunque i prezzi pubblici. */ + protected readonly res = rxResource({ + params: () => (this.store.approved() ? { page: this.page(), q: this.q()?.trim() ?? '' } : undefined), + stream: ({ params }) => + this.api.get>('/shop/products', { + query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, q: params.q }, + }), + }) + + protected readonly euro = formatEuro + protected discount(p: ShopProductSummaryDto): number | null { + return p.priceType === 'installer' ? discountPercent(p.fromPrice, p.fromOriginalPrice) : null + } + + constructor() { + inject(Title).setTitle('Listino riservato | Area riservata ITI TECHNOLOGY') + effect(() => this.query.setValue(this.q() ?? '', { emitEvent: false })) + } + + protected search(): void { + const q = this.query.value.trim() + void this.router.navigate([], { queryParams: { q: q || null, pagina: null } }) + } +} diff --git a/apps/private/src/app/pages/installer/profile-form.ts b/apps/private/src/app/pages/installer/profile-form.ts new file mode 100644 index 0000000..97fd867 --- /dev/null +++ b/apps/private/src/app/pages/installer/profile-form.ts @@ -0,0 +1,68 @@ +import { isInItaly } from '@iti/shared' +import type { AbstractControl, ValidationErrors } from '@angular/forms' +import type { InstallerProfileInput } from '../../core/installer-profile.store' + +/** Valori grezzi del form del profilo installatore (tutti testo, come nei campi). */ +export interface InstallerFormValue { + businessName: string + vatNumber: string + phone: string + address: string + serviceRadiusKm: string + lat: string + lng: string + certifications: string[] +} + +export const RADIUS_MIN = 1 +export const RADIUS_MAX = 300 +export const MAX_CERTIFICATIONS = 20 + +/** Numero decimale scritto all'italiana o all'inglese ("45,58" o "45.58"); null se vuoto o non valido. */ +export function parseDecimal(text: string): number | null { + const t = text.trim().replace(',', '.') + if (!/^-?\d+(\.\d+)?$/.test(t)) return null + return Number(t) +} + +/** Raggio di servizio: intero tra 1 e 300 km. */ +export function radiusValidator(control: AbstractControl): ValidationErrors | null { + const t = String(control.value ?? '').trim() + if (!/^\d+$/.test(t)) return { radius: true } + const n = Number(t) + return n >= RADIUS_MIN && n <= RADIUS_MAX ? null : { radius: true } +} + +/** + * Coordinate: entrambe vuote (nessuna posizione) oppure entrambe numeri dentro l'Italia. + * Validatore del gruppo, con l'errore riportato sul campo latitudine. + */ +export function coordinatesError(lat: string, lng: string): string | null { + if (lat.trim() === '' && lng.trim() === '') return null + const la = parseDecimal(lat) + const ln = parseDecimal(lng) + if (la === null || ln === null) return 'Inserisci latitudine e longitudine (es. 45,5845 e 9,2744), oppure scegli il punto sulla mappa' + if (!isInItaly({ lat: la, lng: ln })) return 'La posizione deve essere in Italia' + return null +} + +/** Corpo di `PUT /installer/profile` dai valori del form. */ +export function toProfileInput(v: InstallerFormValue): InstallerProfileInput { + const text = (s: string) => (s.trim() === '' ? null : s.trim()) + const lat = parseDecimal(v.lat) + const lng = parseDecimal(v.lng) + return { + businessName: v.businessName.trim(), + vatNumber: text(v.vatNumber), + phone: text(v.phone), + address: text(v.address), + location: lat !== null && lng !== null ? { lat, lng } : null, + serviceRadiusKm: Number(v.serviceRadiusKm.trim()), + certifications: [...new Set(v.certifications.map((c) => c.trim()).filter(Boolean))], + } +} + +/** Coordinate da mostrare nei campi ("45.58451" → "45,58451"). */ +export function formatCoordinate(value: number): string { + return String(value).replace('.', ',') +} diff --git a/apps/private/src/app/pages/installer/technical-docs.ts b/apps/private/src/app/pages/installer/technical-docs.ts new file mode 100644 index 0000000..7eff81e --- /dev/null +++ b/apps/private/src/app/pages/installer/technical-docs.ts @@ -0,0 +1,152 @@ +import type { DocumentDto, Paginated } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { map } from 'rxjs' +import { ApiService, toApiError } from '../../core/api.service' +import { DownloadService, fileName } from '../../core/download.service' +import { formatBytes, formatDate } from '../../core/format' +import { InstallerProfileStore } from '../../core/installer-profile.store' +import { Empty, Loading, LoadError } from '../../shared/state' +import { fileKind } from '../customer/documents' +import { ApprovalGate } from './approval-gate' + +/** Categorie presenti nei documenti (alfabetiche, senza duplicati né vuote). */ +export function categoriesOf(docs: DocumentDto[]): string[] { + return [...new Set(docs.map((d) => d.category?.trim()).filter((c): c is string => Boolean(c)))].sort((a, b) => a.localeCompare(b, 'it')) +} + +/** + * Documentazione tecnica: i documenti con visibilità `installer` (manuali, CAD, certificazioni, listini PDF…), + * filtrabili per categoria con `?categoria=`. Le categorie sono quelle assegnate dall'admin ai documenti. + * Solo per installatori approvati: l'API non li restituisce agli altri. Quelli per tutti i clienti stanno in + * `/documenti`. + */ +@Component({ + selector: 'app-technical-docs', + imports: [RouterLink, Loading, Empty, LoadError, ApprovalGate], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Documentazione tecnica

    + + @if (store.approved()) { + @if (res.isLoading()) { + + } @else if (res.error()) { + + } @else if (docs().length) { + @if (categories().length > 1) { + + } + @if (downloadError(); as message) { + + } + @if (visible().length) { +
      + @for (d of visible(); track d.id) { +
    • + + {{ d.title }} + + {{ d.category ?? 'Senza categoria' }} · {{ kind(d.mimeType) }} · {{ bytes(d.sizeBytes) }} · {{ date(d.createdAt) }} + @if (d.productRef) { + · prodotto {{ d.productRef }} + } + + + +
    • + } +
    + } @else { + + } + } @else { + + } + } @else { + + } + `, + styles: ` + .filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-4); + + a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-ink); + text-decoration: none; + + &[aria-current='page'] { + border-color: var(--color-primary); + background: var(--color-primary-soft); + color: var(--color-primary-strong); + font-weight: var(--font-weight-semibold); + } + } + } + `, +}) +export default class TechnicalDocs { + private readonly api = inject(ApiService) + private readonly downloads = inject(DownloadService) + protected readonly store = inject(InstallerProfileStore) + readonly categoria = input() + + protected readonly res = rxResource({ + params: () => (this.store.approved() ? true : undefined), + stream: () => + this.api + .get>('/documents', { query: { limit: 100 } }) + .pipe(map((page) => page.items.filter((d) => d.visibility === 'installer'))), + }) + protected readonly docs = computed(() => this.res.value() ?? []) + protected readonly categories = computed(() => categoriesOf(this.docs())) + protected readonly category = computed(() => { + const c = this.categoria() + return c && this.categories().includes(c) ? c : null + }) + protected readonly visible = computed(() => { + const c = this.category() + return c ? this.docs().filter((d) => d.category?.trim() === c) : this.docs() + }) + protected readonly downloading = signal(null) + protected readonly downloadError = signal(null) + + protected readonly kind = fileKind + protected readonly bytes = formatBytes + protected readonly date = formatDate + + constructor() { + inject(Title).setTitle('Documentazione tecnica | Area riservata ITI TECHNOLOGY') + } + + protected async download(doc: DocumentDto): Promise { + this.downloadError.set(null) + this.downloading.set(doc.id) + try { + await this.downloads.download(`/documents/${doc.id}/download`, fileName(doc.title, doc.mimeType)) + } catch (err) { + this.downloadError.set(`Download di "${doc.title}" non riuscito: ${toApiError(err).message}`) + } finally { + this.downloading.set(null) + } + } +} diff --git a/apps/private/src/app/pages/not-found.ts b/apps/private/src/app/pages/not-found.ts new file mode 100644 index 0000000..65847c9 --- /dev/null +++ b/apps/private/src/app/pages/not-found.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core' +import { Title } from '@angular/platform-browser' +import { RouterLink } from '@angular/router' +import { AuthStore } from '../core/auth.store' +import { homeFor } from '../core/roles' + +/** Pagina inesistente (dentro il layout autenticato). */ +@Component({ + selector: 'app-not-found', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

    Pagina non trovata

    +

    L'indirizzo non corrisponde a nessuna pagina dell'area riservata.

    + Torna alla tua area + `, +}) +export default class NotFound { + private readonly auth = inject(AuthStore) + protected readonly home = computed(() => { + const role = this.auth.role() + return role ? homeFor(role) : '/login' + }) + + constructor() { + inject(Title).setTitle('Pagina non trovata | Area riservata ITI TECHNOLOGY') + } +} diff --git a/apps/private/src/app/shared/badge.ts b/apps/private/src/app/shared/badge.ts new file mode 100644 index 0000000..89f28ca --- /dev/null +++ b/apps/private/src/app/shared/badge.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import type { StatusLabel } from '../core/labels' + +/** Badge di stato (ordine, preventivo, ticket): testo sempre visibile, il colore è solo un rinforzo. */ +@Component({ + selector: 'app-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ status().label }}`, + host: { class: 'badge', '[attr.data-tone]': 'status().tone' }, + styles: ` + :host { + display: inline-block; + padding: 0.15rem var(--space-3); + border-radius: var(--radius-pill); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + white-space: nowrap; + background: var(--color-surface-alt); + color: var(--color-ink); + } + :host([data-tone='info']) { + background: var(--color-primary-soft); + color: var(--color-primary-strong); + } + :host([data-tone='success']) { + background: var(--color-secondary-soft); + color: var(--color-success); + } + :host([data-tone='warning']) { + background: var(--color-accent-soft); + color: var(--color-warning); + } + :host([data-tone='danger']) { + background: #fdecea; + color: var(--color-danger); + } + `, +}) +export class Badge { + readonly status = input.required() +} diff --git a/apps/private/src/app/shared/confirm-dialog.ts b/apps/private/src/app/shared/confirm-dialog.ts new file mode 100644 index 0000000..b51b30b --- /dev/null +++ b/apps/private/src/app/shared/confirm-dialog.ts @@ -0,0 +1,95 @@ +import { ChangeDetectionStrategy, Component, type ElementRef, input, output, viewChild } from '@angular/core' + +let nextId = 0 + +/** + * Dialogo di conferma modale (`` nativo: focus intrappolato, Esc chiude, sfondo inerte). + * Si apre con `open()`; emette `confirmed` o `cancelled`. Il contenuto proiettato va sotto il messaggio + * (es. campi aggiuntivi come la password). + */ +@Component({ + selector: 'app-confirm-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +

    {{ title() }}

    +

    {{ message() }}

    + +
    + + +
    +
    + `, + styles: ` + dialog { + width: min(520px, calc(100vw - 2 * var(--space-4))); + padding: var(--space-5); + border: 0; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + + &::backdrop { + background: rgb(18 33 43 / 45%); + } + } + h2 { + font-size: var(--font-size-xl); + } + .actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-5); + } + .btn--danger { + background: var(--color-danger); + color: #fff; + } + `, +}) +export class ConfirmDialog { + readonly title = input.required() + readonly message = input.required() + readonly confirmLabel = input('Conferma') + readonly danger = input(false) + readonly busy = input(false) + readonly confirmed = output() + readonly cancelled = output() + + protected readonly id = `dialog-${++nextId}` + private readonly dialog = viewChild.required>('dialog') + private cancelling = false + + open(): void { + const el = this.dialog().nativeElement + if (!el.open) el.showModal() + } + + close(): void { + const el = this.dialog().nativeElement + if (el.open) el.close() + } + + /** Esc: chiude come "Annulla", ma non mentre l'operazione è in corso. */ + protected onEscape(event: Event): void { + if (this.busy()) { + event.preventDefault() + return + } + this.cancelling = true + } + + protected cancel(): void { + this.cancelling = true + this.close() + } + + protected closed(): void { + if (this.cancelling) this.cancelled.emit() + this.cancelling = false + } +} diff --git a/apps/private/src/app/shared/form-errors.ts b/apps/private/src/app/shared/form-errors.ts new file mode 100644 index 0000000..4441ff7 --- /dev/null +++ b/apps/private/src/app/shared/form-errors.ts @@ -0,0 +1,31 @@ +import type { FormGroup } from '@angular/forms' +import type { ApiError } from '../core/api.service' + +/** + * Riporta sui campi gli errori di validazione dell'API (`details[].path` = `body.`), come errore + * `{ server: messaggio }` (mostrato da FormField). Restituisce true se almeno un errore è finito su un campo. + */ +export function applyServerErrors(form: FormGroup, error: ApiError, aliases: Record = {}): boolean { + let applied = false + for (const detail of error.details) { + const field = detail.path.replace(/^body\./, '') + const control = form.get(aliases[field] ?? field) + if (!control) continue + control.setErrors({ server: detail.message }) + control.markAsTouched() + applied = true + } + return applied +} + +/** Messaggio per gli errori più comuni delle pagine di autenticazione. */ +export function authErrorMessage(error: ApiError): string { + switch (error.code) { + case 'RATE_LIMITED': + return 'Troppi tentativi ravvicinati: attendi un minuto e riprova.' + case 'NETWORK_ERROR': + return 'Servizio non raggiungibile: controlla la connessione e riprova.' + default: + return error.message + } +} diff --git a/apps/private/src/app/shared/form-field.ts b/apps/private/src/app/shared/form-field.ts new file mode 100644 index 0000000..ef8cfb2 --- /dev/null +++ b/apps/private/src/app/shared/form-field.ts @@ -0,0 +1,113 @@ +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 | 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 'minlength': + return `Almeno ${(value as { requiredLength: number }).requiredLength} caratteri` + 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 (o textarea con `rows`), 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: ` + + @if (hint()) { +

    {{ hint() }}

    + } + @if (rows()) { + + } @else { + + } + @if (error(); as message) { +

    {{ message }}

    + } + `, + host: { class: 'form-field' }, +}) +export class FormField { + readonly control = input.required>() + readonly label = input.required() + readonly type = input<'text' | 'email' | 'tel' | 'password'>('text') + readonly autocomplete = input(null) + readonly inputmode = input(null) + readonly maxlength = input(null) + /** Se impostato, area di testo con quel numero di righe invece di un input. */ + readonly rows = input(null) + readonly hint = input(null) + readonly required = input(true) + readonly submitted = input(false) + /** Messaggi per errori specifici (es. `{ pattern: 'CAP di 5 cifre' }`). */ + readonly messages = input>({}) + + 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()) + }) + } +} diff --git a/apps/private/src/app/shared/leaflet-esm.d.ts b/apps/private/src/app/shared/leaflet-esm.d.ts new file mode 100644 index 0000000..3cac32d --- /dev/null +++ b/apps/private/src/app/shared/leaflet-esm.d.ts @@ -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' +} diff --git a/apps/private/src/app/shared/list-filters.ts b/apps/private/src/app/shared/list-filters.ts new file mode 100644 index 0000000..cdb669f --- /dev/null +++ b/apps/private/src/app/shared/list-filters.ts @@ -0,0 +1,128 @@ +import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core' +import { FormControl, ReactiveFormsModule } from '@angular/forms' +import { Router, RouterLink } from '@angular/router' + +let nextId = 0 + +export interface FilterOption { + /** Valore del parametro di query; null = nessun filtro ("Tutti"). */ + value: string | null + label: string +} + +/** + * Filtro a pillole fatto di link (`?=valore`, torna a pagina 1): refresh, back/forward e link + * condivisi mostrano lo stesso elenco. + */ +@Component({ + selector: 'app-filter-pills', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styles: ` + .pills { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-ink); + text-decoration: none; + + &[aria-current='page'] { + border-color: var(--color-primary); + background: var(--color-primary-soft); + color: var(--color-primary-strong); + font-weight: var(--font-weight-semibold); + } + } + `, +}) +export class FilterPills { + readonly param = input.required() + readonly options = input.required() + readonly current = input(null) + readonly label = input('Filtra') + + protected query(value: string | null) { + return { [this.param()]: value, pagina: null } + } +} + +/** Ricerca testuale: all'invio aggiorna `?q=` (pagina 1). Il campo segue l'URL (back/forward). */ +@Component({ + selector: 'app-search-box', + imports: [ReactiveFormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styles: ` + .search { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + } + .row { + display: flex; + gap: var(--space-2); + } + input { + min-width: 0; + width: 16rem; + max-width: 100%; + min-height: 40px; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-ink-muted); + border-radius: var(--radius-md); + font: inherit; + } + `, +}) +export class SearchBox { + private readonly router = inject(Router) + readonly value = input() + readonly label = input('Cerca') + readonly placeholder = input(null) + + protected readonly id = `search-${++nextId}` + protected readonly text = new FormControl('', { nonNullable: true }) + + constructor() { + effect(() => this.text.setValue(this.value() ?? '')) + } + + protected search(): void { + const q = this.text.value.trim() + void this.router.navigate([], { queryParams: { q: q || null, pagina: null }, queryParamsHandling: 'merge' }) + } +} diff --git a/apps/private/src/app/shared/location-picker.ts b/apps/private/src/app/shared/location-picker.ts new file mode 100644 index 0000000..128a6d0 --- /dev/null +++ b/apps/private/src/app/shared/location-picker.ts @@ -0,0 +1,148 @@ +import { isInItaly } from '@iti/shared' +import { DOCUMENT } from '@angular/common' +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + DestroyRef, + type ElementRef, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core' +import type { Circle, CircleMarker, Map as LeafletMap } from 'leaflet' + +/** CSS di Leaflet copiato negli asset da scripts/vendor-assets.mjs. */ +const LEAFLET_CSS = 'vendor/leaflet/leaflet.css' +/** Centro e zoom con cui si apre la mappa senza una posizione: l'Italia intera. */ +const ITALY_CENTER: [number, number] = [42.2, 12.5] +const COLOR = '#0b5c7a' + +/** + * Selettore della sede su mappa OpenStreetMap: un clic imposta il punto, il cerchio mostra il raggio di + * servizio. Il punto deve stare in Italia (stessi confini dell'API, `ITALY_BOUNDS` di @iti/shared). + * + * Va caricato su richiesta (`@defer` dopo un clic), come la mappa del sito pubblico: Leaflet e le tile di + * tile.openstreetmap.org partono solo quando l'utente apre la mappa. Per chi non usa il mouse il profilo ha anche + * i campi latitudine/longitudine, sincronizzati con la mappa. + * ⚠️ CSP (prompt 34): `img-src https://tile.openstreetmap.org`. + */ +@Component({ + selector: 'app-location-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    + @if (outside()) { + + } + `, + styles: ` + .map { + width: 100%; + height: 380px; + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + z-index: 0; + cursor: crosshair; + } + p { + margin: var(--space-2) 0 0; + } + `, +}) +export class LocationPicker { + readonly location = input<{ lat: number; lng: number } | null>(null) + readonly radiusKm = input.required() + readonly locationChange = output<{ lat: number; lng: number }>() + + protected readonly outside = signal(false) + private readonly document = inject(DOCUMENT) + private readonly host = viewChild.required>('host') + private L: typeof import('leaflet') | null = null + private map: LeafletMap | null = null + private marker: CircleMarker | null = null + private circle: Circle | null = null + private lastRadius = 0 + private destroyed = false + + constructor() { + afterNextRender(() => void this.create()) + inject(DestroyRef).onDestroy(() => { + this.destroyed = true + this.map?.remove() + }) + // Posizione o raggio cambiati (clic, campi del form): marcatore e cerchio seguono + effect(() => this.sync(this.location(), this.radiusKm())) + } + + private async create(): Promise { + const [L] = await Promise.all([import('leaflet/dist/leaflet-src.esm.js'), this.loadStyles()]) + if (this.destroyed) return + this.L = L as unknown as typeof import('leaflet') + const start = this.location() + this.map = L.map(this.host().nativeElement, { scrollWheelZoom: false }).setView( + start ? [start.lat, start.lng] : ITALY_CENTER, + start ? 10 : 6, + ) + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap', + }).addTo(this.map) + this.map.on('click', (e) => { + const point = { lat: round(e.latlng.lat), lng: round(e.latlng.lng) } + const inside = isInItaly(point) + this.outside.set(!inside) + if (inside) this.locationChange.emit(point) + }) + this.sync(this.location(), this.radiusKm()) + } + + private sync(location: { lat: number; lng: number } | null, radiusKm: number): void { + const { L, map } = this + if (!L || !map) return + if (!location) { + this.marker?.remove() + this.circle?.remove() + this.marker = this.circle = null + return + } + const position: [number, number] = [location.lat, location.lng] + const radius = Math.max(1, radiusKm || 0) * 1000 + if (this.marker && this.circle) { + this.marker.setLatLng(position) + this.circle.setLatLng(position).setRadius(radius) + } else { + this.circle = L.circle(position, { radius, color: COLOR, weight: 2, fillColor: COLOR, fillOpacity: 0.12 }).addTo(map) + this.marker = L.circleMarker(position, { radius: 8, color: COLOR, weight: 3, fillColor: COLOR, fillOpacity: 0.8 }).addTo(map) + } + // Inquadra tutta l'area servita quando cambia il raggio (o al primo punto), non a ogni clic + if (radius !== this.lastRadius) { + this.lastRadius = radius + map.fitBounds(this.circle.getBounds(), { padding: [16, 16] }) + } + } + + /** Aggiunge (una volta) il CSS di Leaflet e attende che sia caricato. */ + private loadStyles(): Promise { + const existing = this.document.querySelector(`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) + } + }) + } +} + +/** 5 decimali ≈ 1 metro: abbastanza per una sede, senza precisione finta. */ +function round(value: number): number { + return Math.round(value * 1e5) / 1e5 +} diff --git a/apps/private/src/app/shared/pager.ts b/apps/private/src/app/shared/pager.ts new file mode 100644 index 0000000..df680ab --- /dev/null +++ b/apps/private/src/app/shared/pager.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core' +import { RouterLink } from '@angular/router' + +/** Pagina corrente da `?pagina=` (1 se assente o non valida). */ +export function pageFromQuery(value: string | undefined): number { + const n = Number(value) + return Number.isInteger(n) && n >= 1 ? n : 1 +} + +/** Paginazione "Precedente / Successiva" con link veri (`?pagina=n`): back/forward e refresh funzionano. */ +@Component({ + selector: 'app-pager', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (pages() > 1) { + + } + `, + styles: ` + .pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-4); + margin-top: var(--space-5); + } + a { + display: inline-flex; + align-items: center; + min-height: 44px; + } + `, +}) +export class Pager { + readonly page = input.required() + readonly total = input.required() + readonly pageSize = input.required() + protected readonly pages = computed(() => Math.max(1, Math.ceil(this.total() / this.pageSize()))) +} diff --git a/apps/private/src/app/shared/state.ts b/apps/private/src/app/shared/state.ts new file mode 100644 index 0000000..c1dd1cd --- /dev/null +++ b/apps/private/src/app/shared/state.ts @@ -0,0 +1,103 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core' +import { ApiError } from '../core/api.service' + +/** Messaggio per l'utente da un errore di caricamento. */ +export function loadErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + if (error.code === 'SHOP_UNAVAILABLE' || error.status === 503) return 'Il servizio non è disponibile in questo momento.' + if (error.code === 'NETWORK_ERROR') return 'Impossibile contattare il server: controlla la connessione.' + return error.message + } + return 'Si è verificato un errore imprevisto.' +} + +/** Caricamento in corso (annunciato agli screen reader). */ +@Component({ + selector: 'app-loading', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `

    {{ label() }}

    `, + styles: ` + .state { + display: flex; + align-items: center; + gap: var(--space-3); + margin: 0; + padding: var(--space-4) 0; + color: var(--color-ink-muted); + } + .spinner { + width: 1.1rem; + height: 1.1rem; + border: 2px solid var(--color-line); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation-duration: 2.5s; + } + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `, +}) +export class Loading { + readonly label = input('Caricamento…') +} + +/** Nessun elemento da mostrare, con un testo e un'azione facoltativa (contenuto proiettato). */ +@Component({ + selector: 'app-empty', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +

    {{ message() }}

    + +
    + `, + styles: ` + .empty { + padding: var(--space-5); + border: 1px dashed var(--color-line); + border-radius: var(--radius-md); + text-align: center; + color: var(--color-ink-muted); + + p:last-child { + margin: 0; + } + } + `, +}) +export class Empty { + readonly message = input.required() +} + +/** Errore di caricamento con "Riprova". */ +@Component({ + selector: 'app-load-error', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styles: ` + p { + margin-bottom: var(--space-3); + } + .btn { + min-height: 40px; + } + `, +}) +export class LoadError { + readonly error = input() + readonly retry = output() + protected message = () => loadErrorMessage(this.error()) +} diff --git a/apps/private/src/environments/environment.ts b/apps/private/src/environments/environment.ts new file mode 100644 index 0000000..53144c1 --- /dev/null +++ b/apps/private/src/environments/environment.ts @@ -0,0 +1,12 @@ +/** + * Valori di default (sviluppo locale). In produzione vengono sovrascritti da `/config.json`, letto all'avvio + * (vedi core/app-config.ts): la stessa build vale per qualunque dominio. + */ +export const environment = { + /** URL dell'API Fastify visto dal browser, senza slash finale. */ + apiUrl: 'http://localhost:3000', + /** Sito pubblico (apps/public): link "Torna al sito", privacy, redirect dopo il login. */ + siteUrl: 'http://localhost:4200', + /** Dashboard admin di Medusa (catalogo, prezzi, listini, ordini): link dal pannello admin. */ + medusaAdminUrl: 'http://localhost:9000/app', +} diff --git a/apps/private/src/index.html b/apps/private/src/index.html new file mode 100644 index 0000000..d912d13 --- /dev/null +++ b/apps/private/src/index.html @@ -0,0 +1,16 @@ + + + + + Area riservata | ITI TECHNOLOGY + + + + + + + + + + + diff --git a/apps/private/src/main.ts b/apps/private/src/main.ts new file mode 100644 index 0000000..e95d975 --- /dev/null +++ b/apps/private/src/main.ts @@ -0,0 +1,8 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { App } from './app/app' +import { appConfig } from './app/app.config' +import { loadRuntimeConfig } from './app/core/app-config' + +loadRuntimeConfig() + .then((config) => bootstrapApplication(App, appConfig(config))) + .catch((err: unknown) => console.error(err)) diff --git a/apps/private/src/styles.scss b/apps/private/src/styles.scss new file mode 100644 index 0000000..3b9952c --- /dev/null +++ b/apps/private/src/styles.scss @@ -0,0 +1,263 @@ +// Stili globali dell'area riservata: token, mixin e base sono gli stessi del sito pubblico (packages/styles). +@use 'base'; +@use 'mixins' as mq; + +body { + background: var(--color-surface-alt); +} + +h1 { + font-size: var(--font-size-3xl); +} + +// Pannello bianco (form, riquadri) +.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); + + @include mq.up(md) { + padding: var(--space-6); + } +} + +.btn--block { + width: 100%; +} + +.btn[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +// Pulsante che sembra un link +.link-button { + padding: 0; + border: 0; + background: none; + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 0.15em; + cursor: pointer; +} + +// --- Pagine dell'area riservata --- + +// Intestazione: titolo + azioni +.page-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-5); + + h1 { + margin: 0; + } +} + +.back-link { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-bottom: var(--space-2); +} + +.section { + margin-bottom: var(--space-6); + + h2 { + font-size: var(--font-size-xl); + } +} + +// Elenco di elementi cliccabili (ordini, preventivi, ticket, documenti) +.item-list { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.item { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2) var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + background: var(--color-surface); + + &__main { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; + } + &__title { + font-weight: var(--font-weight-semibold); + overflow-wrap: anywhere; + } + &__side { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + } +} + +// Link che rende cliccabile tutta la riga +a.item { + color: inherit; + text-decoration: none; + + &:hover { + border-color: var(--color-primary); + } + @include mq.focus-ring; +} + +.meta { + font-size: var(--font-size-sm); + color: var(--color-ink-muted); +} + +.amount { + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +// Coppie etichetta/valore +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-2) var(--space-4); + margin: 0; + + dt { + color: var(--color-ink-muted); + } + dd { + margin: 0; + } +} + +.btn--small { + min-height: 40px; + padding: var(--space-1) var(--space-4); +} + +.btn--danger { + background: var(--color-danger); + color: #fff; + + &:hover { + color: #fff; + filter: brightness(0.92); + } +} + +// --- Pannello admin --- + +// Tabella dei dati: scorre in orizzontale dentro il suo contenitore sugli schermi stretti +.table-wrap { + overflow-x: auto; + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + th, + td { + padding: var(--space-3); + border-bottom: 1px solid var(--color-line); + text-align: left; + vertical-align: top; + } + th { + background: var(--color-surface-alt); + font-weight: var(--font-weight-semibold); + white-space: nowrap; + } + tbody tr:last-child td { + border-bottom: 0; + } + tbody tr:hover { + background: var(--color-surface-alt); + } + .num { + text-align: right; + white-space: nowrap; + } + a { + font-weight: var(--font-weight-semibold); + } +} + +// Barra dei filtri sopra un elenco: pillole + ricerca +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +// Riga di pulsanti +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +// Campo select o input compatto inline (cambio stato) +.inline-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + + label { + font-weight: var(--font-weight-semibold); + } + select, + input { + min-height: 40px; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-ink-muted); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-ink); + font: inherit; + } +} + +// Griglia a due colonne da desktop (dettaglio + colonna laterale) +.split { + display: grid; + gap: var(--space-5); + + @include mq.up(lg) { + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + align-items: start; + } +} + +// Testo lungo inserito dall'utente (messaggi, note) +.pre-line { + white-space: pre-line; + overflow-wrap: anywhere; +} diff --git a/apps/private/tsconfig.app.json b/apps/private/tsconfig.app.json new file mode 100644 index 0000000..281dc21 --- /dev/null +++ b/apps/private/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/apps/private/tsconfig.json b/apps/private/tsconfig.json new file mode 100644 index 0000000..2edae2e --- /dev/null +++ b/apps/private/tsconfig.json @@ -0,0 +1,35 @@ +/* Estende la base del monorepo (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes) con le opzioni per Angular. */ +{ + "extends": "../../tsconfig.base.json", + "compileOnSave": false, + "compilerOptions": { + "lib": ["ES2022", "ES2023.Intl", "dom", "dom.iterable"], + "module": "preserve", + "moduleResolution": "bundler", + "declaration": false, + "declarationMap": false, + "noPropertyAccessFromIndexSignature": true, + "experimentalDecorators": true, + "importHelpers": true, + "outDir": "./out-tsc" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true, + "typeCheckHostBindings": true, + "extendedDiagnostics": { + "defaultCategory": "error" + } + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/private/tsconfig.spec.json b/apps/private/tsconfig.spec.json new file mode 100644 index 0000000..9c8efb9 --- /dev/null +++ b/apps/private/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/apps/public/.env.example b/apps/public/.env.example new file mode 100644 index 0000000..fcd5326 --- /dev/null +++ b/apps/public/.env.example @@ -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= diff --git a/apps/public/Dockerfile b/apps/public/Dockerfile new file mode 100644 index 0000000..7bd4abf --- /dev/null +++ b/apps/public/Dockerfile @@ -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"] diff --git a/apps/public/angular.json b/apps/public/angular.json new file mode 100644 index 0000000..b521ce8 --- /dev/null +++ b/apps/public/angular.json @@ -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" + } + } + } + } +} diff --git a/apps/public/package.json b/apps/public/package.json new file mode 100644 index 0000000..d08294c --- /dev/null +++ b/apps/public/package.json @@ -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" +} diff --git a/apps/public/public/favicon.ico b/apps/public/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8c2e2205ad8516228e8c1e90a68a493451ad48b1 GIT binary patch literal 196 zcmZQzU<5)11qKkwu#SO&K@5mH1N_{1xum#&OkPh9mmnam0Kyz>K(e^1>I;x6^>lFz zskrs_+Ck0(3Oub3@9a)eFI>FtIaAdqC8>qaGN*{2`1#*qi|E5SR_CQJU8{&JZ}{Q4 zN?dHc;MyIQi`XwbO6v&Qqa(B6=zI2_M|up(EH>*D7o@M>%Y7l6-TllFUjY^Y2L{I5 o@lN9Ro9sJO_dh!QSmsH}$`j8N(-*lr10BHN>FVdQ&MBb@0AkHWc>n+a literal 0 HcmV?d00001 diff --git a/apps/public/public/favicon.svg b/apps/public/public/favicon.svg new file mode 100644 index 0000000..77cf1b7 --- /dev/null +++ b/apps/public/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/public/public/images/aree/acqua.svg b/apps/public/public/images/aree/acqua.svg new file mode 100644 index 0000000..b3816b0 --- /dev/null +++ b/apps/public/public/images/aree/acqua.svg @@ -0,0 +1 @@ + diff --git a/apps/public/public/images/aree/comfort.svg b/apps/public/public/images/aree/comfort.svg new file mode 100644 index 0000000..18d3b8f --- /dev/null +++ b/apps/public/public/images/aree/comfort.svg @@ -0,0 +1 @@ + diff --git a/apps/public/public/images/aree/energia.svg b/apps/public/public/images/aree/energia.svg new file mode 100644 index 0000000..d6fa94e --- /dev/null +++ b/apps/public/public/images/aree/energia.svg @@ -0,0 +1 @@ + diff --git a/apps/public/public/images/aree/industria.svg b/apps/public/public/images/aree/industria.svg new file mode 100644 index 0000000..5d11505 --- /dev/null +++ b/apps/public/public/images/aree/industria.svg @@ -0,0 +1 @@ + diff --git a/apps/public/public/images/aree/mobilita.svg b/apps/public/public/images/aree/mobilita.svg new file mode 100644 index 0000000..9e2e3da --- /dev/null +++ b/apps/public/public/images/aree/mobilita.svg @@ -0,0 +1 @@ + diff --git a/apps/public/public/images/showroom/acqua.svg b/apps/public/public/images/showroom/acqua.svg new file mode 100644 index 0000000..9fb72f1 --- /dev/null +++ b/apps/public/public/images/showroom/acqua.svg @@ -0,0 +1 @@ +Area trattamento acqua (segnaposto) diff --git a/apps/public/public/images/showroom/energia.svg b/apps/public/public/images/showroom/energia.svg new file mode 100644 index 0000000..a735967 --- /dev/null +++ b/apps/public/public/images/showroom/energia.svg @@ -0,0 +1 @@ +Area energia e comfort (segnaposto) diff --git a/apps/public/public/images/showroom/sala.svg b/apps/public/public/images/showroom/sala.svg new file mode 100644 index 0000000..f9bcb79 --- /dev/null +++ b/apps/public/public/images/showroom/sala.svg @@ -0,0 +1 @@ +Foto showroom (segnaposto) diff --git a/apps/public/public/logo.svg b/apps/public/public/logo.svg new file mode 100644 index 0000000..77cf1b7 --- /dev/null +++ b/apps/public/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/public/public/og-default.png b/apps/public/public/og-default.png new file mode 100644 index 0000000000000000000000000000000000000000..40a5f2b3aac84246b8d1e20b6510408ebb054706 GIT binary patch literal 29797 zcma*Q3tW_S+CDyjfTE5^zypY7YH^TSL-7FS=3%pvHX8>q2ewiX8xa+q91#;!fs}vs zVx@^KzDDs_g9W4FfS{74YcAPXF(RiW6B_}GAw~3m-S_vIVK94t`*Alv!pt+@!+p5! z>po2Sb8~0+^%~}-P$>F7^|u+%D-=Bn6bje1Jw5P~s3-IGwaXjP%PIUA0+z=A)3COO|}3zqI1)|1AEvI`{Y9n@0BBJhJCoZ#T!UNL&%OB>nB} zuFt8yI+*dV-ybaXU*4FM)9l@9x_hJM&S~}C#E$06ZOy6Qbf%QtY(G9Hr9JQNo#e0D zKfKeP*l+C3jT4&mkN?t9GjC(o*^Q}H|It5xrK4S{(QHagz1eD`B0BlceD zI9uHOXj%I&+nT?qs;O*gHeA?nx6^R`Z0fqx8&9{Do=?4{>GAvAi>ax5uIZ0S*a~|LI11+%N6tTAJ(3&9|%9 zE#KeV*4SM6Uh}J0+cQ-)4N{F1QhMQR%{$j>Le(2XJyY+x8iu(ZoZr$EJN$%{dd=T3 z;$)D{-SAhpi+@ip+pK=8rODm2SuM3THTSJkJ-x}@P^-G2YpXWtk`w=Gy0uo zNJ~?4yy@0V7gL}jdO%Cl2)x6kHL%r6Pr6fYQze*gEzi^?_c7FZn^tvrY09M^HhB*= zY>e!sF`H7{4Y~E^(DpfQK6szG^~{Cx=qS%Rm7i(yCUr~TNX=2aqBc7(ttDYdT)9+F zYcNmHwSD-IX0PvQX<=P!)0)RM+FsE)$`e7=K|1rp{EoL9@RnlrJGJJT^=0&Pcf+O9 z&1y@Xd0ns8eDig)v}#kBn?~yr-7=@pA4~dK*J#VWo*h30tFYE)Th#b@^@ZuW(N*K{ zr*xQMLXTj)M0FC+I-qwq%&^pXmX#fehty_QOvhP?MJsx1(v91&gp50D3dWm;xkMM! z9{llB{p6nbz!Nx}k#Xht?_eC!;-u#atLs0kC||4#>t8@m!X9X2!xs4s#wJ?MSn1fr zR;Oed7yA8cpf;dawD>viH(0qI55~5Dc(Do`M>3*Wv!lJc~`MZUTChBWS=E1tS8CS0>9t*obsAAjb&0J zREA`q~hl?!s~o=)HT04U?+!sfCT#SY!OQ&;JMuKAwYSTH4Rdumrj7ia&-OvuvuaKN-o%J~hlh+lbkl~v__2y;Krroe4s%Bl|)eo<$I%MN_G==OPFP{wD;qV{k z6_&)BcK0k8IK!?s!jhhl20PwETdrE<_l&=`xsPFtg0GPc-M1wPeeYQ}nFp-(c4pex zwATDb;}-aRS27ScNh0qMO%~NNc-^sh{m%G~uU|cEc!_sL+rWxR)$IbtM(bg$I#@Zk zA1Rc>fP0&oezytpAY0eQbmO#v+N`bWH`oBf$atqDtqj1ylR03k>dwZu()P`M>ro%i z*aIgd*G=SB$behaWajxWVRY+mDeX;@0b_i>04-63|&zw@&SxTBdM7vL6I0OIDb(gqxa!Z@llM+d859rB{ z!+RtSGsOVQ*loW!LtGZ!qp+lKjBt%Z*wL|$jx@~)kdF}PicwGyTj`UtEr~Nz;kB1k zNLk8pE^rI-^~gw!Un}LbLc224a;-j|AxdLGxX#<)qc~gtq5QCPW8x-ZHk=HYeko~q zuJy58Yo@Mk6jYTgifra&Q04VHy{$_CV^2y}=oa5RU1EPtx3stBY0H;=g?Ynttm4tzO4!4g}bBUC&7A#1WWoZGcC4E*pGxs zkoAJ8xWjEbjLq3^8oxg+?XBFz`X;Ma7i9d>u8|o)RaKa>ydKo$w8P6cFb)gFPF3Cza0#%etyQ`%-QcD)Ea>+yo1?juzs>$=rEldfIBLiv< z6XM+{8_<@7C8nw(KmkAo6|TdL*)GW**_KbJ;_Y!tB_A1q z4mdUd6FyIGe#Z$Z8ZhsXlzUVVY|Bq(lg@IO);2 z!p^ti_2(;XdyDaM_OBgV_>K1o3J6rV8!cLn^xxT8R zd`Ye~ba#_x(T3SO6ua!kLHZomY4=(3cGuDJ7L8Mm*=Y=|S3Q7hD^^!J5T<7{A{zIeM@mgONY`Uv~E|Isy;=pc$6s_2$?9YMNXWe&hj)@LCe)kPUCo z@9cJTvLj4~BK`6R7ML*A|I$El+XJ=h>>#$tS*>;E>-BL>@p>=Q`T-t>wK*$#L<9H> z<3grigg^3j>8^9FhkQMwip!<#e^$*b!=7XcJ(Ni-kOSX_WTUVH>>wn1F(}76!2J@T4|e|YmJy~SvTfL zN|-Bl$2c||rtbdw44^U7vVW?-p9^55>=wvT$R1R+mQRv=YJ{~uZhWO}V{4!X)Km@> zv)iGhb_dA{uzrWJCUmH{?Z`vn;e!nxe*hVPnuNs&N|8j*1d4gO!Tf8vE*UVL9V0^p z2bvTxjXXKI$9=k_m8&5^^Xf^ZjqH$aAs_{SdamNje0C)c;aZ{>}@!uxT88$j;o3 z_V^e{dn;|fRodR!LSbeTho@xF=&VjiALUy2Io?J;%78v+ruCg=*|)PZr?W?NOQ5m~ zObS&Ndtm!T_B29Mn38wKDlMG`?Vy0d$I~tgg(>>7$=)oS!9n!$#TV1v6LB=H&)6T{(&jgb(9C`Y`SrM{q;zv{p6LR-{kU(ab? zThzw`l|n0-GlF)=L`S+a?uhXV&QhmCe}wuBlcWAirF3*`8A`q&YeCoc&rNO8Q|#~^ zcf=SnaB`RHB(mqSpC(&&Eb^&F4(RT>a;;&lO@8><`O@(4zL0GK?Q%3tK?D&JmA0Sh zT2xinDGIRtsrGZmtE|IjymH>yCa}A6W++R7PSQSyGOMJB}JGc-Q{e3+-nfu ze#5mjL8Oxt>&yn_VVD2wemNAOVED>*m}>J+Slg;l`Lg~jz@-pS|0aPAtp|u70qtOs zWFKbty8y+a}l06btTpRGBkLS_~ zX~qBJl1ZQ8zrD4wrvOc`yV;hz56S*YSQE*kCEIcDfj%zN}T4 zO!}md26Jeza;>-a_)rAo*XbOg<$sDf7Q*Ev!V`^vUlHH|-et4;H3~6Ul?j=LD1YKm zw|P=k>7dT5wSO}e_knLAvv-L-grEbvkyR1j*Vlq|Ta#4Kj}V=(Ny2&@Lm2xuS$c|p zbFIsKJ@?Sb1pio4tYj{ipLxc?HON$ks+ZwT^WB^8eqBl*YHlP6{UN@C$m*l}t*PP? z9aNi}u4p;is`EQk7yfzi8C_cu>Ehgd$`Pk_8lzrUyFsX!;}VO?l9^m_^o^~7uF+c0 zm|W|xbutk7;p_TZk4@%KHsa=u4gf{ZV4@|*#Srs^^pE$zOKOu^{z;!^%e9V@T~8Mp zh;X2RDyycbkNW!uXd!eF&A2i`A{3M}u&^us=o3=TzY|6;A%N1nB&nr`=G+$ba>dgW z8M9I{p+x{m#Aioa0^MHxG!5iIrERkCSo;C&-aVdoR*ri3`CZrf`t+M~d$pI4OAo9?i5g%(ARQ91)zMep;6MBbjL( zKp%v9glB-OC~z~ms-AoxXlWtDgHUjJozA;o7mG%Bm}|vRN&gE_8h00l|GOcl25O^< z!hfKk>s)n7j4O)>!}sLZD5W4u!0g6lzwV;EU^Z&OB( z=*+w?Ax+5_<7`86% z1!sst8+@o~mQ1bWhblkXY0Nq$#ZH^@9#~UCnO(@ihkSt$hX?W=k1PLBN$a9OSY+?8eC06NvBHq45ljE2R6M< zwb(T54WLp_zJYZEwC8uI2SMTIq&k#pH^Vt`5FxdyBz$!Lpgx+t6sZ2_>$z~dF{&rh zet$-{xSx=Q59qC#j#xX_>YYmrXur&Q)dD6{WPmAnVUaMJ-jWdVl1nfXq!4QyYn@G} zQg$~F%ngunj6o3K4qdctXU65EbkWCitm_{nfJdY4kWa`mkXI2)Ey(3ri-D)<1;7f0_2&4FT>uW=o^ymUof5(UrMn7J8+WOcN~#m80Ywij(kjwYZ@h^>HDl&C#We z+r5`lB)6h4W$djB<=?a>H%L*L%n&G@DUWk z-2<17ws^gCbDZ1!8_d%9cz!X%l1NIx?EJkF@DoWuZi$hR4doIX+p*m|y)eQZYXzGM zJNdPYS*oQ{hsk4kM`B{+uEcQIyYq*`b*@AUe&dQwz)1;F70SWhp=i6h|Jilp@mlpl zze`_)zZX&!`3U|6Qk0h34GP7)p;nyzegZ_4gm74tXQtc5^OWS|xYA3rxD;vtRU#&) zsosXOJ68n6G-9HF$N$(FukTN{7+6(b8woT`r^ZnLiSZiJ{P`p2XVOo6JUhyzmsm9J zNe9iObWj~#i$}D7Fy(_t1qZETWWkaKt&A)e*RHI;y>E4a%2hX|K^diXD=4@ljI}&7Sw~e^%{Z5F_Z!QX>0{9wTLUP$f82;#WgqOu`03%M88C z5*`C4ey*@&Nnz*5$fqplN8_&TBqikvaH)ZCCI|mRcDv4w9>a!#)a*tP%ieA)2<{tU z{jls`LzLG^1KV_AUV;IomCmr_Ie9nIELV;M_x%G4NeS0<{&e$+vRm)p5ApbGMsJwh z^AN17K2~6z>RR&*xe4w^wh+#g-2hl6cDN^237HijNMQ>;a5)_YAT@KZpL$L~QkT*%S zOa}1Yu<4OL_~ALmH0!nk{dY^(uzqFx0!mf&AcN`&gGzZb&Dx}usgG(FIY|7>20KMB zM|P>)0Vj);PFbh6$Q5Tm$nsVA3NcSg}t1 zUj}n54!b^?B4uI2M2N4=!e-;z`Z6hyVVu2w^6wFvzC8{q1*JsP1mgzs@gm0A-Z;D` zbb$UOw%Xml5CbccgKjz)|9I<`kwV?6*M@N6~@uRMTKq;nlM(?I)-D zzvve2KkLuvn~Y4_|6*5MwgOVCYN{xA>2}#3B)&F**?>+@197$!tM2ox} zQ16CAtPWuC`%p6Quwm6Y!yPK+sYS(IvEV%jq`#V(hVvrhSA}HJ&p0ykL0#LPh6kXx zkZRtiQ?Nxh{?bjO`@z#BMIp+I8o_xHL)L}u*yMYkf_t(M{am29*h6Q221jIP3_nGB zlET$h4vCj_y737tmCQ9LzzF}TpD7VM2r+diwVW_AdjJS*%JzX|T+vU~^(UlRzs4}G z^mLHoEEjeVwqVyL!V{=7)=rv57Pz$~!TbJ~z4~(LEr{nqN|(_}o}7jJZqsCbv11YC z4kZGj5f@HD!`xi!?9uzJZLLkV(GX~nBjwW||HQRo_j_XQ1+<8^T1!J zULiglVdwn)*0H_z-Km z=mmyu!#` zQH3Q^MY&sM+K&Pd$!TzB@?{}Q=C?S5{8Aj+Kru@dHfOgqUv{9Yv7N--r zDiR-e>V|t3mX7+5q1tI@2A~832PO~zwk3`M_C>$k~gkIUUS-fjwsaAn~|x@e|lUxu;WPkTHfv@0do1)Va} zKAmB4A^(+YMIqp7q*qQzLvxfXs5%o0NZ~Z$L~T9USIR+7>Mza14}GzyE2bgfCqWrr`+hARhuZLm9@gWdl0U(9W zJ;_n^vvr_UbCi?&OA1(8kf~u+8y~7F?ZX*OqB;Yqh(oUA5e|wR&Snk-9Bbg z0ijE$!R~=jj`;YmfzbhFU85JB04CWUuSY(;()Mta=l6Iv83X~@SdPgQf<1mc`gLp_ z#lt_z>D`20uy21%FXXGpBVq?Hd-t!tNBQw~sk813<=ZPIlX zH@%@+YPgwe4N_b?#PZ-&cSr$8!VtQg1ndfoW6U)po z43oF1>wG;^#}CqUqN?@&_wG-R&d8c?c=v>Ps;*6NlCKu(!w8f(j?ZD9>C&$ad~wGq z=@DPg0A|}4C#{_3t;q&-a1DrRX?Gbd+Kf1OfmigRTJwV473J_Z zJ#=mVs;@Ox+WvwlgJ8@h`J}r_X(xR;a(;;E%nzK<+`GoG^W~YA)m-jD>?vbkxem4u zB7)W9+4T};ej+pNw57qkq)R*ko1GF4W(e4-tq;uqXMD$~N?Xo;>y%2WiEqkGJKk%H zIv~pfgcZSr?ygM=1dv$z)z?MgzY~}(4F6#nmBCWwIUurp(fIVWI`0wSQGQ$V@1zM& z9#k@XCn53G26#o&!|@7t! zk#L$bz){@_B^WwDN~7u*P9aYVlvBv3$4czMu4mH&ggR9weBB4j@XM6ait<5> z>T9WT|rs&&};@?LE!x$!PxKHuz+Rf@| zcbleE)%S2c7z61hNVOOhOMs}NSd*&41%Rld1{BVtkALi?q9Y>XMA?aVGl<~q*G=L@ zS`1e$Jx2%h6!P-58;E#;Afu3N%1Oy6#|2~;Km$`aB-eg_5RGii<-7H@tMSuE|5IP9 zGFA02pvD9aqU@DAYhWw#fMXg@QP{pFGtHCW2DIrgf(Q}e-cz5Emk4(pLa}M4v3;1f zhKhw`nurV!Rw6}Go@skEJymgVq(IBa@}R<~vL%7)Scw#oLnJ#y(>4CKBf1w#-na}2 zc*k3t=qaR@+z+#Sf-c%Qe$-2gGmZNmA_OQB%PzQG_G#(H8o4fx+j_jqR>e?>zKFy1 z5b&LM)~tL;(^r9f1w#Ts$+2ywTbqHxNRP5DcG_$ zVCzYLM4)H7((53sbyI8EzCWzc!f)o5_3dQU;Pz4r<+QY&VAg zKJ@`j1QMCZBa3bp0e_(fXyNB&m$vg2Ks2$`AT)u2uk;CSb^uZ#Q z2aO%hS&TenR^aibC5)-sc=K2+B5CS&kgr^&f!b_SKLwn4W|{KcTJi%DT(KdvGAznq-7{22HSTpz?BoxUKZfh{#=508^ z1~OII1$rpqcZjr5hPwx5X7@2Y*5DM{IubXH;l27V{|MFPU*ly>tCCvYuPEQO-}-Lp zyQ^L7T{XZqcDVC)Jp^?X)j^YHhwIvo3^(m|>`veWu2fT<05ER+&y#ZIq5#fJi-P#olVqgc4eT?lqCV~?LfX#~U)jhMniE{Z;jH>#<#xgvq}x&J zIuZya-IFt9fH!vwBNM|c3S_qN-a9tY9k0P1qfF~rXw1xO zn$EQY5foGZji9z=xKQi;?6W{m6ZbD0+HplfBfH?x{GXF?=2f z%#OunRd81QHL6%@X$eSIA5Xd2=7Vi$P;+B%AL8XxMd9gX{W(QR@_t}Hk>`_eYW6O^ z%C&%7s^>*fM%<)t7MErCLJqhO;pz6Kv8Jj%f z)!6HF3;dS$j2pe(7=S7n0a8}YuuSBF^EEK_f4JwLX2Xb+V|ur`^bAnehYwR{rGr2b zri~b=OR!mfONU}NRGp4;A9T3%fvH`TrLvH#sa4_tN<1i^p2cC<@o{{G#!v3x+13R1H$*A;h?= zwFdpLfJSAjJ_tcTAGcp11@7HQrt8J0li?*tDzC^jdgu6|aYe^i8pl;>xV(89B}uNM zh_tHR%&$l2~W?pc5oU>K+FRStt-XFaV&vYezKFLwK2A_W46JqYh zIlRN+?*aNLp?z%l-3>R!K?b-g!`Mjm)zDxPDd3ZOK8#uy#{b`+QiJX ztUB{Ex-hExCo4s~qoe#ZL87Y$Dka-G@$+!qt4S;8m~K^HRnNmYQ+Jcw-n)xkLU1oL+1}IJ4H?G=}8#LQfpHy}B z7`~N@UL{HypZ-Kx+P}9pCH>V@9N@!)2>w{i+quTD@dP5e@YS3!)sF)9^zmHYVD=E2 z?rloAjcqtKoZ^lc=Jfl63XECowt5;wNgC3(qg?Kz^TE0zS31ug?y{?&vdGUgafsmz zap3Z)(8F8uM{;p&*izJoOq#ornLJ-@Kd{*oEmmm&nKZMgx1ov%Im{cD1^Vf9FDfV| zmu9W6-AXUCm%f%u3+$!ahIG0nSC@;^e}QJ9jrz`C3nPY6#3%=t2B(aEuC|?f- zDL+^x#1Q?SuQT`Enb~bMaF^MO{CY(19MlKKT7(ncI9LK8_AUqN z{-vNTH3`w{+>vs=2O}svxD=PpxJw9ufE%0WBGWzi=x}&Ve__^^ESm5gWG+Q*`a<5_ zoJ~dlv6<&$jKDTD8ngzo0spke!A=`%UH6!yiZA^I(SVHv4lZ3in6MRDAH=mnqU@LM z9a5!)?h6}~Y4k)u-}~1!E4f9U#jjgS5u~ZmUEKJY`r4kR7&I!vLXu5kqsf{!2dQeD z!?R@>)cHczvH^7}ICIW3InyMA*rE;F)HapO2$$85qntoj)j8N)cY_zd?4mt)iuS}9 zlPEctPBGVwu2_nycAtRPN6+#YhgRudkzRF9k{b~r_-Da9-RU2eR&W!2CXRUT#HLnBGKAcdac?!j_NN~Ahvfroh1g@|FcOC zy3Eg%5wYghRirw-eg7CoNnXg3`;8q+gbP@dWrdxEuN0Oqjm{c@G=vdU`&7kT@PP7R7FUS41^26+BaOt*$%(U4gqTuIzTN7My z?DQ|TE_1jU8d;3xoc_I0+dc2pf#XE zmnU_IoHs}D2-QwXgK4*UMHY1ti9GIOcd_3526_bB7J!oaK{DP85$RNECF4^v(!2u|m5VjZ zD1k|ii2-imp50`bvY;pOvau}WWAMUf+@t*kW%B)-#(h6+H~Nw-lL2Bsr^aS)RIuJH z9o=u|1$Rxv(?=jNZ~y_ODg~mg6S75e%4}&kV~7vvegd;A z96iov_PCGWBY}kOPy+8rrESUu)_h?5arT^}FsUFa!MY#vTZ)_@YVLMY?a>AWweW_! z3R)6sT9+EHnXELNXJ zomoJpaora*AmAeIMWkE=$;|k%+(gV$xI1fq+(ax!g%Y-5!o~;s-P8K48ib;$hJPia zY_zB_{JqIlIj)9MuQc^K&AS}&JMn!eWyw#A@A$Sk62^8}VyvU=tVJ`C<{(g*OpztG zQ{=B>4ghyE-gN8gYQM3=a3M^gAAk2z8nW^k!8S)kAqOi+#}>tR{DmWy{)q6P?W&y> z~xsIXtCYK9nKZPaC_HoWY!G^q1R94)w>mu|+gG z&!jR3LBs)twfVryR$A1c_^H07CI4(i`MV%n>&-ulL-^^WDn6d$XIPqFK-kx);Pw~? zmm`_&bM8ny`NxVfw#C2v&c7rk; zHvGlGj(!ncY1;#+D1&1A&@{4YesK{;?qJDug~Ul7>AX#6FOc)f{2asdcCo-4c68HN*Q*%Y-hCu&!*yJ!18%Rcz16&D-M)r0h#Wz~ zxAt4tp&hu2YhRsaelYr1HN|%Xl6LXt=_oMLTTEkknZm@P*h@8u9 zK7Y7-8%$9;G>r_mf$G|q1<>@REOM$9qjItCOw@VDXO}mFsh9jrD)(teEoW>@J6f{Fqp}xqgK*+qPkUn?VFpFPFmpah$vC+0wf zz#GBJFEfpXlhVRz8+Q((7Ms<<%E+y-2~Uor{0|kV-rIhQNg4aCiKq}p#Jat422G;F zl%Xu*gHPjskPILkMqz}+g>q)5RTQhwvZRt7BpgOH5BKSoivp701_!Xr!;nKTsrm7$ zje%7o`mzg_TjQPlcE<;3JKS|)f&=>W*E~B^8$g!qU1&DFviroi%N{t^r5e+jAu>e= zggv*g(*u~-fgQ(mc|7+FqvvUCvrPIAdg~BBVTaIrBt?+!MU@dd#*|6jy-cpPt)*#O zbK9}=NoP*NPcG*iV82=_4ZJ1u1LR3$pFo;59NiMD@=>7y%J-byBJ=cGj-17ahM(zG zY=%glF!kkjL4Qs4`rl4o32$o+d>K2XV?94q8|#WxQIcctG7hcEK<49fNgY0q`Nn&r*a&D#xK}30$US1zp`9EKd)RaJpA(6Y44E z`gmR=&?WOw_CyaVyQBR~H32y0yun!1x>BO_Q0@SXX`;;|!INxnXBW1zqI?tPgfv8s z9r|MDZ^#$>dd{0|nMVktj~oG#9Aooh>=M$phnJXAl3Vwdbda|46g3be?X@5oRfI#} z2Pj_)Ho{kS=_nz5L!G(r!lW04^qodUd{KD#Xievx+s%Vq=KmqVh=tRPm}x1;HxclS zph0x$#&&MgUq)F4N(|fsk0300WzzRStqHBGW420HrfnqWa@mfOz}uq6@Du8Mz9+Rz zYC=O}l1wok(aoi&M@r8g3Cx0VPvX&Q#Fr{hOP}9gbRNi2jyow;oL}btS&sS_AqH{* z2wIKJ_k*uiw!K)G;$gZKW@-Z6jw(0KDJL#rFHJ>B z983$X^YMJS*1WjPb-~~3=spH(F%5KDrav9;V&*1jQki2o79IAs3}aS#(EsP%_p=;P zlmx()rCur$%vK870po~kEy{c3 zX&De&N16*=q%BQuy0E9)6CT!tY&^bY!L(d+>y#t;X}ITqI9iqs+_IcHq%X>ksVo}W zMWBHMn)(&j!l(8yY;-+X5gHKV?^`Ba5x=gqwQtH^p0sk^qdbpGR2!2bP)8B{ztVIYxl`~sb>092ynnr z!&Sg-stT{017-nhCU`*8wy3XCHF%wB9>@|^({*!1rEPu-n7R~K)B3yCMV)g4r<*g| zqRpeJcLehXx2e794JX$ayf|-0VT!{H6GC5#l7ZLNdo~1=+||6eq2uM_KCMk(agrpl zNS}-$l8@6>hn5bDK#~DEDlBv=bigTbi9QD$_`)Jy>cdPTkB+U`p)V-rH6&N98Ayh!le$w(p zTJnq8mVW4{OC{V-^=~D;OjRKnf1Clvl$MbC#3WJY%oVb5aKBnxn>^WUTcu0YQYeH( zCR=GL8BKw8F8jeK7=ish4N#Hu6(xEQ1|Gk5UAQ*@sSf z8RU@3SW*%&)9Cx9glqzZC@3%Vl$#03dlqvMt1vmt2FsK-9Lu`r0p}_t%3xAUgB?Mz z7saurtFMqrx5*}*Q*qmIbqBdY#z{2zV|I`;%{KC^Lz%>vg9H7g_>R8(85G4sy9sg& za*5V^l}k8@L9Da^t5feTAEToXh^K;Kf)FgneyiRd_i|DWy$U=5ahmBhD#($&rfl~> z`6nc`guwpAlS|%v-JC?}2#o$ketx^UoQk*|Ktsekj{)qVzQ*ZLUlxbDr{0`J9!b@o zWt9b!=sVH~iu;s?&Gt8aPkMkvbGJBvXE=bp+is-M&n<_j%R{!ST&qbM)2~PLdHI|i*vdAJn*kWlKdSNP(AoAXMGhG*FtIZ2 zcBbBusl%wZ04g>CPe=oiF1|?#JF0RQJ<<6Of|;bZJL#wFz9-4Y+8Gho$fTEj@D5F^ zu)`zzKt}W<;Dc$-3jS4TgZV|)E`-|A)kHR;C@5n%oR_F7gAd3ju7zJZqRRsGh?_}q znH;b2U2Ca)%06@Fq?93|wlHLjD|Q-}o|5d7_SpWAN3XPfPS-h!QC!J8ED#-XzI3@; zSB}h{=6QLRecT_f=7cVpz#WY39Mf(tvP<~IIbDamXwDyMo-ZXS^ZTPNuN=y^IBH|LAAg^fMUoaR1`Czqddtta>(qsQuIUpfu zPTYNkg0-6t*q7WhWtHQmiP+x}ZHwV&Gc0Lnq7=X>*ZLHX6(ZQP0J1}zY(1%OG>X1hD*)DRAwi0^orw15@_W1UB~^4DMrREcz_ zX+i`zF+qj1Mr=L{S+mH>(rzFXZ@_3?Yt5&5Ado@$M5@yuJ&V`7n{FvIZ6)EqpCXyd z6D03$@FVC~syB~Koaow>Xy7Ct`c-(pdhn2bz`fM{cxObxD&$|*rpaF1K3j)dyXme` z0r+!$tuyix345vtcB07W@-TU!(zfsu*|cI!7m1}nR7 zK96JvPCe;=!yZ)vUIetRahh62p({>of!{B^;9XGPf+<~S-Vzmgk-j*}*(ao}oXnu2 zHad4$8bahVEz&q{OJ@@R+qJYMAMFEJ4to(cL|i8gz|Ard_oQ>pY|pNg{X_;jw0sgi z*%K1(J0GAukMT5Qfk~z@;p|PB|(5j^5|)?0T3ob3=^oK znOO$8PD$XJ2;}%#QZkK8;iILGqde2#Wym|#skZ$$DC%(d5SlOr^9YHj;fzPYlN1vn#!SOhihz52t z{S=8(pg+Y?XvN+*k23k|+Vv19hlgYFpp#t)DJAEhl2cKhVz;kmsxnjUA$N{DBVQov zQa{Q%iPJ@^oanoO9{HP!osw>g87DdRvX-3-d0c;ucexaRJvTb%hebHb-=hE$xPx>J zaL9@d-zbE^4PX(`;^BpSli=hj8?}wE@!Pc!hYVxCV<|e%!l4%HWcq zE-siWBZz~H+MW>I8o)q&qWhJlOyYWpk>ohSkT=wG%Whc{;Yy zEnSprUF_>wLzPzqt}ItI{-;GzVlG;EnX8v)bc>nUs3u0)e~SGyVd0rFL#75s4~=5^ zPSYHzWkAHDWIf91`SeGRnMIlE@raaGb>v^TZuVtOqgowLh#=<3bxlK#lo1}NEgIwi z1AtoM@l*;99o6s*6T4tV)rf8<=2a1Fi$Ek1Mf%;VODkQgpn?-H%ty zXJd&YKeI!9j2Y7@Ca=NT)RDL*O)Jn0_{s59@=|gozQfVW{q^B@Tz6f^SEl?|SPB~C zQ&75>1`KcmjsxPs9fZ&zG6(ACYA0l~3Be~k$oV+N-S+%>12qyf7s zvA&!T!sPDYs#T{Y`>EPX=RFXj4kTPk zLk&k#r?5yV&aj*WLi-<{;vIDs_G48C`D9TF2_?`cG8qXymm$Cm3r0!QsqSW`sax}D z#sXmwgtyWNF^Zhvsc@oBCp?eD_z)G}hK1TQ^)k#rctjJ)?m<_0NZ-WRlo(~fWQtF^ zXPEyV01F~C9Q?rjCFMQ!Mfp%pNR)bQzE5t&8-r(E{{Nx)|TmfRpP!~eL9?@M_B1{_6@zsYu$gk%txWDu5;r)>j4$&uAw z1U6!1B$-K=Yco|dk!zHRFEesUgGg-lMmmy?Fn)g8yM=x%bK%!XrFpqRxhOc9D$SK~tk0tME?1>bpX9t~^#LgEdRopC5%yN~vG zHbt2N(tII*6-7E_=aStEb}~7l*CMsjRaEtVfGe1V0<;hC7bA6Rq#9k7gZ|{P?65Fh z3*|h|P`m{9D^aT5A)(xJF94z@Ica4-Cwo8?;kDML*~kE*l$@>pe}S*SMV~)eYtF0L zL0!U}sUV|6v;uqRlADx_FMrYw0uW?P%(b3vN$^9S`6^9({ckYV<-#56lX3-~yccc< zqkcJJtB{Zvk?UGrHnzpcwJ#@$x*F5GG|2(l<0#ZJ1PZ3%`wf$Ie$)^Ju~p z1YhkE(+@9pOch}Ao5ocHby0x!xnBEr$pP1Ko+v)w&Cz@nI0^Y>;`Shh!oVUv;bJTq zB&1ADY_(?LKz30#ev>$Ib|LWlbnZ`GCGN4Bo1Jj;wkJF1aaD?mfS| z;+OV!MN=2iwe?6p2{%R*ALj-O^~o$H^uELRZi_Zx{8oY8K%8EVLk@?v4A+rjL}o+EUwA) zO;@!3cL4T}MQFV3i5gXsGil-YW(rD{BaHQRa26baZ5-IsaOX$}w$$y517=u8)tc`^ zWySdY@;Kpyd2H)D_;mXP zF{t-rvl?^b$x{3|4$HOfPmJwcO@vH0Dm;Z;Hoja*gl{c@*Vr@a^N6S_c6_f$MXU30 zaT5LIVvOQ8_1@W*SakoQ1fB+GA0>h|;ebB5wexfWSJOJB2I+h8utX6Hg(`y3HR%Sw zQV;~AQ8O6H`}+%xU_-n3PlE8GW1UjP-GaF}#m)#f%WgA536-Le8DAY3<7G$UWLxcC z4IWN({sa9?iSj&8d>8cLc|HZ6&PiYLLROU^F`Y67UyG?mq+-biuLkq`_=OGSL)pMW z6r0JV(`cvR8xw+0CKd%Ul<`Y>B<{eykiAw_26>}`*eOT^kHD9Sd@1qB7a=UOs2d>V dZf9!2M!nY 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)' : ''}`, +) diff --git a/apps/public/scripts/vendor-assets.mjs b/apps/public/scripts/vendor-assets.mjs new file mode 100644 index 0000000..ef892ee --- /dev/null +++ b/apps/public/scripts/vendor-assets.mjs @@ -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`) diff --git a/apps/public/src/app/app.config.server.ts b/apps/public/src/app/app.config.server.ts new file mode 100644 index 0000000..e1de4e1 --- /dev/null +++ b/apps/public/src/app/app.config.server.ts @@ -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) diff --git a/apps/public/src/app/app.config.ts b/apps/public/src/app/app.config.ts new file mode 100644 index 0000000..2a4a0c0 --- /dev/null +++ b/apps/public/src/app/app.config.ts @@ -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 })), + ], +} diff --git a/apps/public/src/app/app.routes.server.ts b/apps/public/src/app/app.routes.server.ts new file mode 100644 index 0000000..4bb34d3 --- /dev/null +++ b/apps/public/src/app/app.routes.server.ts @@ -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, + }, +] diff --git a/apps/public/src/app/app.routes.ts b/apps/public/src/app/app.routes.ts new file mode 100644 index 0000000..9f86040 --- /dev/null +++ b/apps/public/src/app/app.routes.ts @@ -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') }, +] diff --git a/apps/public/src/app/app.ts b/apps/public/src/app/app.ts new file mode 100644 index 0000000..2eaf9e7 --- /dev/null +++ b/apps/public/src/app/app.ts @@ -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: ` + + +
    + +
    + + + @if (consent.panel() !== 'closed') { + @defer { + + } + } + `, + 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() + }) + } +} diff --git a/apps/public/src/app/core/api.service.spec.ts b/apps/public/src/app/core/api.service.spec.ts new file mode 100644 index 0000000..4ba5581 --- /dev/null +++ b/apps/public/src/app/core/api.service.spec.ts @@ -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 }) + }) +}) diff --git a/apps/public/src/app/core/api.service.ts b/apps/public/src/app/core/api.service.ts new file mode 100644 index 0000000..d9adfde --- /dev/null +++ b/apps/public/src/app/core/api.service.ts @@ -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 + +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(() => 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(path: string, options: RequestOptions = {}): Observable { + return this.http + .get(this.url(path), httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + post(path: string, body: unknown, options: RequestOptions = {}): Observable { + return this.http + .post(this.url(path), body, httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + patch(path: string, body: unknown, options: RequestOptions = {}): Observable { + return this.http + .patch(this.url(path), body, httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } + + delete(path: string, options: RequestOptions = {}): Observable { + return this.http + .delete(this.url(path), httpOptions(options)) + .pipe(catchError((err: unknown) => throwError(() => toApiError(err)))) + } +} diff --git a/apps/public/src/app/core/app-config.ts b/apps/public/src/app/core/app-config.ts new file mode 100644 index 0000000..3586253 --- /dev/null +++ b/apps/public/src/app/core/app-config.ts @@ -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('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('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('API_BASE_URL', { + providedIn: 'root', + factory: () => inject(SITE_CONFIG).apiUrl, +}) diff --git a/apps/public/src/app/core/auth.interceptor.ts b/apps/public/src/app/core/auth.interceptor.ts new file mode 100644 index 0000000..6b783a3 --- /dev/null +++ b/apps/public/src/app/core/auth.interceptor.ts @@ -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, 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))) +} diff --git a/apps/public/src/app/core/blog.ts b/apps/public/src/app/core/blog.ts new file mode 100644 index 0000000..1ed6737 --- /dev/null +++ b/apps/public/src/app/core/blog.ts @@ -0,0 +1,14 @@ +/** + * URL del blog: elenco `/blog`, per categoria `/blog/categoria/` (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' +} diff --git a/apps/public/src/app/core/cart.store.ts b/apps/public/src/app/core/cart.store.ts new file mode 100644 index 0000000..26a9e76 --- /dev/null +++ b/apps/public/src/app/core/cart.store.ts @@ -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(null) + private readonly _state = signal('idle') + private readonly _pending = signal(0) + private readonly _error = signal(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 = 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('/shop/cart', this.options), true) + } + + /** Ricarica dall'API (es. ritorno sulla scheda dopo modifiche fatte altrove). */ + reload(): Promise { + return this.run(() => this.api.get('/shop/cart', this.options), true) + } + + add(variantId: string, quantity = 1): Promise { + return this.run(() => this.api.post('/shop/cart/items', { variantId, quantity }, this.options)) + } + + setQuantity(itemId: string, quantity: number): Promise { + return this.run(() => + this.api.patch(`/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 { + 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(`/shop/cart/items/${encodeURIComponent(itemId)}`, { quantity }, this.options) + }) + } + + remove(itemId: string): Promise { + return this.run(() => this.api.delete(`/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 { + 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 { + 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 | null, read = false): Promise { + 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 + } +} diff --git a/apps/public/src/app/core/checkout.spec.ts b/apps/public/src/app/core/checkout.spec.ts new file mode 100644 index 0000000..9939bb8 --- /dev/null +++ b/apps/public/src/app/core/checkout.spec.ts @@ -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 { + 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 €') + }) +}) diff --git a/apps/public/src/app/core/checkout.ts b/apps/public/src/app/core/checkout.ts new file mode 100644 index 0000000..a91bab3 --- /dev/null +++ b/apps/public/src/app/core/checkout.ts @@ -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 + +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 + +/** 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 + +/** 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) { + 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 = { 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' +} diff --git a/apps/public/src/app/core/configurator.spec.ts b/apps/public/src/app/core/configurator.spec.ts new file mode 100644 index 0000000..2d66bc1 --- /dev/null +++ b/apps/public/src/app/core/configurator.spec.ts @@ -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) => 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, 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') + }) +}) diff --git a/apps/public/src/app/core/configurator.ts b/apps/public/src/app/core/configurator.ts new file mode 100644 index 0000000..9243c99 --- /dev/null +++ b/apps/public/src/app/core/configurator.ts @@ -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 + +/** Errori per domanda (id → messaggio). */ +export type WizardErrors = Record + +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 + /** 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 = {} + 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 | 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." oppure "body.answers."). */ +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 + 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 + } +} diff --git a/apps/public/src/app/core/consent.service.ts b/apps/public/src/app/core/consent.service.ts new file mode 100644 index 0000000..cb83e21 --- /dev/null +++ b/apps/public/src/app/core/consent.service.ts @@ -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(null) + private readonly _panel = signal('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 { + 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() + } + } + } +} diff --git a/apps/public/src/app/core/consent.spec.ts b/apps/public/src/app/core/consent.spec.ts new file mode 100644 index 0000000..d0288b5 --- /dev/null +++ b/apps/public/src/app/core/consent.spec.ts @@ -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('' }) + expect(doc.getElementById('jsonld-test')!.textContent).not.toContain('') + + seo.setPage({ title: 'Blog', description: 'x', path: '/blog' }) + expect(doc.head.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(0) + }) +}) diff --git a/apps/public/src/app/core/seo.service.ts b/apps/public/src/app/core/seo.service.ts new file mode 100644 index 0000000..510d995 --- /dev/null +++ b/apps/public/src/app/core/seo.service.ts @@ -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): ``. */ + prev?: string | null + next?: string | null +} + +export type JsonLd = Record + +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 "" non può chiudere il tag + script.textContent = JSON.stringify(data).replace(/ 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 `` nell'head. */ + private setLink(rel: 'canonical' | 'prev' | 'next', url: string | null): void { + let link = this.document.head.querySelector(`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) + } +} diff --git a/apps/public/src/app/core/session-cart.spec.ts b/apps/public/src/app/core/session-cart.spec.ts new file mode 100644 index 0000000..49eb2a5 --- /dev/null +++ b/apps/public/src/app/core/session-cart.spec.ts @@ -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' }), + ]) + }) +}) diff --git a/apps/public/src/app/core/session.service.ts b/apps/public/src/app/core/session.service.ts new file mode 100644 index 0000000..05c0027 --- /dev/null +++ b/apps/public/src/app/core/session.service.ts @@ -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(this.isBrowser ? 'unknown' : 'anonymous') + private readonly _user = signal(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 | null = null + private refreshing: Promise | null = null + + /** Si risolve quando lo stato iniziale è noto (subito sul server e per chi non ha il cookie indizio). */ + ready(): Promise { + 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 { + if (!this.isBrowser) return Promise.resolve(false) + this.refreshing ??= this.withLock(() => this.doRefresh()).finally(() => (this.refreshing = null)) + return this.refreshing + } + + private async init(): Promise { + if (!this.isBrowser) return + if (!this.hasHintCookie()) { + this._status.set('anonymous') + return + } + await this.refresh() + } + + private async doRefresh(): Promise { + try { + const res = await firstValueFrom( + this.http.post(`${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(fn: () => Promise): Promise { + const locks = (globalThis.navigator as Navigator | undefined)?.locks + return locks ? locks.request(REFRESH_LOCK, fn) : fn() + } +} diff --git a/apps/public/src/app/core/shop.ts b/apps/public/src/app/core/shop.ts new file mode 100644 index 0000000..7a1d04c --- /dev/null +++ b/apps/public/src/app/core/shop.ts @@ -0,0 +1,40 @@ +import type { ShopProductDetailDto, ShopVariantDto } from '@iti/shared' + +/** + * URL dello shop: elenco `/shop`, per categoria `/shop/categoria/`, prodotto `/shop/`; + * 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) +} diff --git a/apps/public/src/app/core/showroom.ts b/apps/public/src/app/core/showroom.ts new file mode 100644 index 0000000..6c05a7a --- /dev/null +++ b/apps/public/src/app/core/showroom.ts @@ -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) +} diff --git a/apps/public/src/app/core/site.ts b/apps/public/src/app/core/site.ts new file mode 100644 index 0000000..2fed039 --- /dev/null +++ b/apps/public/src/app/core/site.ts @@ -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', + ] +} diff --git a/apps/public/src/app/core/stripe.ts b/apps/public/src/app/core/stripe.ts new file mode 100644 index 0000000..b3a5569 --- /dev/null +++ b/apps/public/src/app/core/stripe.ts @@ -0,0 +1,35 @@ +import type { Appearance, Stripe } from '@stripe/stripe-js' + +let loading: Promise | 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 { + 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'] diff --git a/apps/public/src/app/core/structured-data.ts b/apps/public/src/app/core/structured-data.ts new file mode 100644 index 0000000..7121c27 --- /dev/null +++ b/apps/public/src/app/core/structured-data.ts @@ -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): 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, + }) +} diff --git a/apps/public/src/app/layout/cookie-banner.ts b/apps/public/src/app/layout/cookie-banner.ts new file mode 100644 index 0000000..487b1c5 --- /dev/null +++ b/apps/public/src/app/layout/cookie-banner.ts @@ -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') { + + } + @case ('preferences') { + + } + } + `, + 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() }) + } +} diff --git a/apps/public/src/app/layout/footer.ts b/apps/public/src/app/layout/footer.ts new file mode 100644 index 0000000..6e5c01e --- /dev/null +++ b/apps/public/src/app/layout/footer.ts @@ -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: ` +
    +
    +
    + +

    {{ company.description }}

    +
    + @if (company.legalName) { + {{ company.legalName }} + } + @if (company.address; as a) { + {{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }}) + } + @if (company.phone) { + Tel. {{ company.phone }} + } + @if (company.email) { + {{ company.email }} + } + @if (company.vatNumber) { + P.IVA {{ company.vatNumber }} + } +
    +
    + + + + + + +
    + + +
    + `, + 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() +} diff --git a/apps/public/src/app/layout/header.ts b/apps/public/src/app/layout/header.ts new file mode 100644 index 0000000..369d98c --- /dev/null +++ b/apps/public/src/app/layout/header.ts @@ -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: ` +
    +
    + + + + + @if (cartCount() > 0) { + + } + + + + + +
    +
    + `, + 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)) + } +} diff --git a/apps/public/src/app/pages/blog-post.ts b/apps/public/src/app/pages/blog-post.ts new file mode 100644 index 0000000..bcd072e --- /dev/null +++ b/apps/public/src/app/pages/blog-post.ts @@ -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()) { + + } @else if (failed()) { + +
    +

    Non è stato possibile caricare l’articolo. Riprova tra qualche istante.

    +
    + } @else if (post(); as p) { +
    + +

    + @if (p.publishedAt) { + + } + @if (p.category) { + {{ p.category.name }} + } +

    +
    +
    + @if (p.coverUrl) { +
    + +
    + } +
    +
    +
    + +
    + + + @if (related().length) { + + } +
    + } + `, + 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() + + private readonly api = inject(ApiService) + private readonly sanitizer = inject(DomSanitizer) + + private readonly postRes = rxResource({ + params: () => this.slug(), + stream: ({ params }) => this.api.get(`/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>('/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 }, + ]), + ) + }) + } +} diff --git a/apps/public/src/app/pages/blog.ts b/apps/public/src/app/pages/blog.ts new file mode 100644 index 0000000..3caca85 --- /dev/null +++ b/apps/public/src/app/pages/blog.ts @@ -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()) { + + } @else { + +
    +
    + + @if (filters().length > 1) { + + } +
    + +

    {{ statusText() }}

    + + @if (failed()) { +

    Non è stato possibile caricare gli articoli. Riprova tra qualche istante.

    + } @else if (page(); as data) { + @if (data.items.length) { +

    Articoli

    +
      + @for (post of data.items; track post.id; let i = $index) { +
    • + } +
    + } @else { +
    + @if (q()) { +

    Nessun articolo trovato per «{{ q() }}»{{ category() ? ' in ' + category()!.name : '' }}.

    +

    Prova con parole diverse o mostra tutti gli articoli.

    + } @else { +

    Non ci sono ancora articoli{{ category() ? ' in questa categoria' : '' }}: torna a trovarci presto.

    + } +
    + } + + } +
    + } + `, + 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() + /** Query params (?q=, ?pagina=). */ + readonly q = input('', { transform: (v: string | undefined) => normalizeQuery(v, BLOG_QUERY_MAX) }) + readonly pagina = input() + + 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('/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>('/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 }])) + } + }) + } +} diff --git a/apps/public/src/app/pages/cart.ts b/apps/public/src/app/pages/cart.ts new file mode 100644 index 0000000..8aa6018 --- /dev/null +++ b/apps/public/src/app/pages/cart.ts @@ -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: ` + +
    + @if (cart.error(); as message) { + + } + + @switch (view()) { + @case ('loading') { +

    Caricamento del carrello…

    + } + @case ('error') { +
    +

    Non è stato possibile caricare il carrello.

    +

    +
    + } + @case ('empty') { +
    +

    Il carrello è vuoto.

    +

    Vai allo shop

    +
    + } + @default { + @if (cart.cart(); as c) { +
    +
    +

    Prodotti nel carrello

    +
      + @for (item of c.items; track item.id) { +
    • +
      + @if (item.thumbnail) { + + } +
      +
      +

      + @if (item.productHandle) { + {{ item.productTitle }} + } @else { + {{ item.productTitle }} + } +

      + @if (item.variantTitle && item.variantTitle !== 'Standard') { +

      {{ item.variantTitle }}

      + } +

      {{ euro(item.unitPrice) }} cad.

      +
      +
      + + + +
      +

      {{ euro(item.total) }}

      + +
    • + } +
    +

    ← Continua gli acquisti

    +
    + + +
    + } + } + } +
    + `, + 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) + } +} diff --git a/apps/public/src/app/pages/checkout.html b/apps/public/src/app/pages/checkout.html new file mode 100644 index 0000000..9f4b27c --- /dev/null +++ b/apps/public/src/app/pages/checkout.html @@ -0,0 +1,367 @@ + + +
    + @switch (view()) { + @case ('loading') { +

    Caricamento del checkout…

    + } + @case ('error') { +
    +

    Non è stato possibile caricare il carrello.

    +

    +
    + } + @case ('empty') { +
    +

    Il carrello è vuoto: non c'è nulla da pagare.

    +

    Vai allo shop

    +
    + } + @default { + @if (cart.cart(); as c) { +
    +
      + @for (s of steps; track s; let i = $index) { +
    1. +
      +

      + + {{ stepTitles[s] }} + @if (isDone(s)) { + (completato) + } +

      + @if (isDone(s) && !locked()) { + + } +
      + + + @if (isDone(s)) { +
      + @switch (s) { + @case ('contact') { +

      {{ email() }}

      + } + @case ('address') { + @if (c.shippingAddress; as a) { +

      + Spedizione: + @for (line of addressLines(a); track $index) { +
      {{ line }} + } +

      + } + @if (billingDifferent() && c.billingAddress; as b) { +

      + Fatturazione: + @for (line of addressLines(b); track $index) { +
      {{ line }} + } +

      + } + @if (c.invoice; as inv) { +

      Fattura a: {{ inv.companyName }}, P.IVA {{ inv.vatNumber }}

      + } + } + @case ('shipping') { + @if (c.shippingMethod; as m) { +

      {{ m.name }} — {{ euro(m.amount) }}

      + } + } + } +
      + } + + + @if (step() === s) { +
      + @if (stepError(); as message) { + + } + + @switch (s) { + @case ('contact') { +
      + @if (authenticated()) { +

      + Stai ordinando come {{ session.user()?.firstName }} {{ session.user()?.lastName }} + ({{ email() }}). Conferma e riepilogo dell'ordine arriveranno a questo indirizzo. +

      + } @else { + + + } +
      + +
      + + } + + @case ('address') { +
      +
      + Indirizzo di spedizione + +
      + + + @if (billingDifferent()) { +
      + Indirizzo di fatturazione + +
      + } + + + @if (wantsInvoice()) { + @let inv = addressForm.controls.invoice.controls; +
      + Dati per la fattura +
      + + + + + +
      +

      Indica il codice destinatario oppure la PEC per ricevere la fattura elettronica.

      +
      + } + +
      + Consensi + + @if (privacyError()) { +

      Il consenso è necessario per completare l'ordine.

      + } + +
      + +
      + +
      +
      + } + + @case ('shipping') { +
      + @if (shippingOptions(); as options) { + @if (options.length === 0) { +

      Nessuna modalità di spedizione disponibile per questo indirizzo. Contattaci per completare l'ordine.

      + } @else { +
      + Modalità di spedizione + @for (o of options; track o.id) { + + } +
      +
      + +
      + } + } @else { +

      Caricamento delle modalità di spedizione…

      + } +
      + } + + @case ('payment') { + @switch (paymentState()) { + @case ('unavailable') { + + } + @case ('loading') { +

      Preparazione del pagamento sicuro…

      + } + @case ('completing') { +

      Pagamento confermato, registrazione dell'ordine…

      + } + } + +
      +
      +
      + + @if (paymentError(); as message) { + + } + +
      + @switch (paymentState()) { + @case ('error') { + + } + @case ('complete-failed') { + + } + @default { + @if (paymentState() !== 'unavailable') { + + } + } + } +
      +

      + Pagamento sicuro gestito da Stripe: i dati della carta non passano dai nostri server. Proseguendo + confermi l'ordine con obbligo di pagamento. +

      + } + } +
      + } +
    2. + } +
    + + +
    + } + } + } +
    diff --git a/apps/public/src/app/pages/checkout.scss b/apps/public/src/app/pages/checkout.scss new file mode 100644 index 0000000..f00bf29 --- /dev/null +++ b/apps/public/src/app/pages/checkout.scss @@ -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); +} diff --git a/apps/public/src/app/pages/checkout.ts b/apps/public/src/app/pages/checkout.ts new file mode 100644 index 0000000..b0c367b --- /dev/null +++ b/apps/public/src/app/pages/checkout.ts @@ -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 = { + 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//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('contact') + protected readonly busy = signal(false) + /** Errore generale del passo corrente (quelli dei campi stanno sui campi). */ + protected readonly stepError = signal(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(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(null) + protected readonly shippingControl = new FormControl('', { nonNullable: true, validators: [Validators.required] }) + + // --- Pagamento --- + protected readonly paymentState = signal('idle') + protected readonly paymentError = signal(null) + private readonly paymentHost = viewChild>('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 { + 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 { + 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 { + 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 { + 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('/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 { + this.shippingOptions.set(null) + try { + const options = await firstValueFrom( + this.api.get('/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 { + const optionId = this.shippingControl.value + if (!optionId) return + this.busy.set(true) + this.stepError.set(null) + try { + const cart = await firstValueFrom(this.api.post('/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 { + // 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('/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 { + 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 { + 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 { + this.paymentState.set('completing') + this.paymentError.set(null) + try { + const order = await firstValueFrom(this.api.post('/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(`#${containerId}-panel [aria-invalid="true"]`) + el?.focus() + }, + { injector: this.injector }, + ) + } +} diff --git a/apps/public/src/app/pages/company.ts b/apps/public/src/app/pages/company.ts new file mode 100644 index 0000000..b17eee8 --- /dev/null +++ b/apps/public/src/app/pages/company.ts @@ -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/.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(); + + + +
    + +
    + @for (section of p.sections; track section.heading) { +
    +

    {{ section.heading }}

    + @for (paragraph of section.paragraphs; track $index) { +

    {{ paragraph }}

    + } + @if (section.items?.length) { +
      + @for (item of section.items; track $index) { +
    • {{ item }}
    • + } +
    + } +
    + } + @if (p.cta; as cta) { + {{ cta.label }} + } +
    +
    + `, + 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() + + 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 }]), + ) + }) + } +} diff --git a/apps/public/src/app/pages/configurator-thanks.ts b/apps/public/src/app/pages/configurator-thanks.ts new file mode 100644 index 0000000..6850dfd --- /dev/null +++ b/apps/public/src/app/pages/configurator-thanks.ts @@ -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: ` + +
    + @if (sent(); as s) { +

    + Grazie {{ s.name }}, abbiamo ricevuto la tua richiesta per {{ s.result.solution.title }}. + Ti abbiamo inviato un riepilogo a {{ s.email }}. +

    + } @else { +

    Abbiamo ricevuto la tua richiesta e ti abbiamo inviato un riepilogo via email.

    + } +

    E adesso?

    +
      +
    1. Un nostro tecnico verifica la stima con le informazioni che ci hai dato.
    2. +
    3. Ti contattiamo entro pochi giorni lavorativi per chiarire i dettagli ed eventualmente fissare un sopralluogo.
    4. +
    5. Ricevi un preventivo definitivo, gratuito e senza impegno.
    6. +
    + +
    + `, + 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(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())) + } +} diff --git a/apps/public/src/app/pages/configurator.html b/apps/public/src/app/pages/configurator.html new file mode 100644 index 0000000..fae711b --- /dev/null +++ b/apps/public/src/app/pages/configurator.html @@ -0,0 +1,377 @@ + + +
    + @switch (view()) { + @case ('loading') { +

    Caricamento del configuratore…

    + } + @case ('error') { +
    +

    Non è stato possibile caricare il configuratore.

    +

    +
    + } + @default { +
    + +
    +

    + Passo {{ currentNumber() }} di {{ totalSteps() }} + @if (position() === 'result') { + — Risultato + } @else if (step(); as s) { + — {{ s.title }} + } +

    + +
    + + @if (position() !== 'result') { + @if (step(); as s) { +
    +

    {{ s.title }}

    + @if (s.description) { +

    {{ s.description }}

    + } + @if (formError(); as message) { + + } + + @for (q of questions(); track q.id) { + @let error = errors()[q.id]; + @switch (q.type) { + @case ('single') { +
    + + {{ q.label }} + @if (!q.required) { + (facoltativa) + } + + @if (q.help) { +

    {{ q.help }}

    + } +
    + @for (o of q.options; track o.value; let first = $first) { + + } +
    + @if (error) { +

    {{ error }}

    + } +
    + } + + @case ('multi') { +
    + + {{ q.label }} + (una o più risposte) + + @if (q.help) { +

    {{ q.help }}

    + } +
    + @for (o of q.options; track o.value; let first = $first) { + + } +
    + @if (error) { +

    {{ error }}

    + } +
    + } + + @case ('number') { +
    + +

    + @if (q.help) { + {{ q.help }}. + } + Da {{ grouped.format(q.min) }} a {{ grouped.format(q.max) }} {{ q.unit }}. +

    +
    + + {{ q.unit }} +
    + @if (error) { +

    {{ error }}

    + } +
    + } + + @case ('province') { +
    + + @if (q.help) { +

    {{ q.help }}

    + } + + @if (error) { +

    {{ error }}

    + } +
    + } + } + } + +
    + @if (stepIndex() > 0) { + + } + +
    +
    + } + } @else if (result(); as r) { + +
    +

    La soluzione consigliata

    +

    {{ r.solution.title }}

    +

    {{ r.solution.description }}

    + @if (r.size) { +

    Taglia indicativa: {{ r.size.label }}

    + } + + @if (r.estimates.investmentCents !== null || r.estimates.annualSavingsCents !== null || r.estimates.paybackYears !== null) { +
    + @if (r.estimates.investmentCents !== null) { +
    +
    Investimento indicativo
    +
    {{ euro(r.estimates.investmentCents) }}
    +
    + } + @if (r.estimates.annualSavingsCents !== null) { +
    +
    Risparmio annuo stimato
    +
    {{ euro(r.estimates.annualSavingsCents) }}
    +
    + } + @if (r.estimates.paybackYears !== null) { +
    +
    Tempo di rientro
    +
    {{ decimal.format(r.estimates.paybackYears) }} {{ r.estimates.paybackYears === 1 ? 'anno' : 'anni' }}
    +
    + } +
    + } + + @if (r.warnings.length) { +
    +

    Da tenere presente

    +
      + @for (w of r.warnings; track $index) { +
    • {{ w }}
    • + } +
    +
    + } + + @if (r.details.length) { +
    + Come abbiamo calcolato +
    + @for (d of r.details; track $index) { +
    +
    {{ d.label }}
    +
    {{ d.value }}
    +
    + } +
    +
    + } + +

    Incentivi

    +

    {{ r.incentivesNote }}

    + +

    {{ r.disclaimer }}

    + +
    + + +
    +
    + + @if (r.products.length) { + + } + + +
    +

    Ricevi un preventivo gratuito

    +

    Un nostro tecnico verifica la stima e ti ricontatta, senza impegno.

    + @if (submitError(); as message) { + + } +
    +
    + + + +
    + + + @if (messageError(); as m) { +

    {{ m }}

    + } +
    +
    + +
    + Consensi + + @if (privacyError()) { +

    Il consenso è necessario per inviare la richiesta.

    + } + +
    + +
    + +
    +
    +
    + } @else { +

    Calcolo del risultato…

    + @if (formError(); as message) { + +
    + +
    + } + } +
    + } + } +
    diff --git a/apps/public/src/app/pages/configurator.scss b/apps/public/src/app/pages/configurator.scss new file mode 100644 index 0000000..cd47c45 --- /dev/null +++ b/apps/public/src/app/pages/configurator.scss @@ -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; +} diff --git a/apps/public/src/app/pages/configurator.ts b/apps/public/src/app/pages/configurator.ts new file mode 100644 index 0000000..ce8844a --- /dev/null +++ b/apps/public/src/app/pages/configurator.ts @@ -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=` (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(null) + protected readonly values = signal({}) + protected readonly position = signal('') + protected readonly errors = signal({}) + /** Errore generale (rete, servizio): quelli delle domande stanno sotto le domande. */ + protected readonly formError = signal(null) + protected readonly busy = signal(false) + protected readonly result = signal(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(null) + protected readonly messageError = signal(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 { + this.view.set('loading') + let def: ConfiguratorDefinition + try { + def = await firstValueFrom(this.api.get('/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 { + const state = this.state() + if (!state) return + this.busy.set(true) + this.formError.set(null) + try { + const result = await firstValueFrom( + this.api.post('/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 { + 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(`[data-question="${CSS.escape(id)}"]`)?.focus(), + { injector: this.injector }, + ) + } + + private focusFirstInvalidContact(): void { + afterNextRender( + () => this.document.querySelector('#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.' +} diff --git a/apps/public/src/app/pages/contact.ts b/apps/public/src/app/pages/contact.ts new file mode 100644 index 0000000..09515ab --- /dev/null +++ b/apps/public/src/app/pages/contact.ts @@ -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=][&prodotto=]` (core/contact.ts). + */ +@Component({ + selector: 'app-contact-page', + imports: [ReactiveFormsModule, RouterLink, PageHeader, FormField, ConsentFields, Honeypot, OsmMap], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
    +
    +

    Scrivici

    + @if (sent()) { +

    + Grazie, abbiamo ricevuto il tuo messaggio. Ti risponderemo al più presto, di solito entro un giorno lavorativo. +

    +

    + } @else { +

    Per informazioni, preventivi o assistenza. I campi senza indicazione sono obbligatori.

    + @if (error(); as message) { + + } +
    +
    + + + + +
    + + + @if (messageError(); as m) { +

    {{ m }}

    + } +
    +
    + + +
    + +
    + + } +
    + + +
    + `, + 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() + readonly soluzione = input() + readonly prodotto = input() + + 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(null) + protected readonly busy = signal(false) + protected readonly sent = signal(false) + protected readonly error = signal(null) + protected readonly mapVisible = signal(false) + private readonly messageVersion = signal(0) + private ticket: Promise | 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(`/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 { + 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 { + 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('#contact-form [aria-invalid="true"]')?.focus(), { + injector: this.injector, + }) + } +} diff --git a/apps/public/src/app/pages/cookie.ts b/apps/public/src/app/pages/cookie.ts new file mode 100644 index 0000000..15bf9d8 --- /dev/null +++ b/apps/public/src/app/pages/cookie.ts @@ -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: ` + +

    Elenco dei cookie

    +
    + + + + + + + + + + + + + @for (row of rows; track row.name) { + + + + + + + + } + +
    Cookie e memorizzazione locale usati dal sito
    NomeFornitoreFinalitàDurataCategoria
    {{ row.name }}{{ row.provider }}{{ row.purpose }}{{ row.duration }}{{ row.category }}
    +
    + +

    La tua scelta

    +

    {{ current() }}

    + +
    + `, + 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' }) + } +} diff --git a/apps/public/src/app/pages/home.html b/apps/public/src/app/pages/home.html new file mode 100644 index 0000000..c312a84 --- /dev/null +++ b/apps/public/src/app/pages/home.html @@ -0,0 +1,158 @@ + +
    +
    + +

    {{ home.hero.title }}

    +

    {{ home.hero.payoff }}

    + +
    +
    + + +
    +

    {{ home.sectors.title }}

    +

    {{ home.sectors.lead }}

    + +
    + + +
    +
    +
    +

    {{ home.energy.title }}

    +

    {{ home.energy.text }}

    +
      + @for (point of home.energy.points; track point) { +
    • {{ point }}
    • + } +
    + Fotovoltaico e accumulo +
    + @if (energy) { + + } +
    +
    + + +
    + @for (group of [home.privateServices, home.businessServices]; track group.title; let business = $last) { +
    +

    {{ group.title }}

    +
      + @for (item of group.items; track item.title) { +
    • +

      {{ item.title }}

      +

      {{ item.text }}

      +
    • + } +
    + + {{ business ? 'Soluzioni per le aziende' : 'Prenota un sopralluogo' }} + +
    + } +
    + + +
    + +
    + + +
    +
    +

    {{ home.projects.title }}

    + Tutte le realizzazioni +
    +
      + @for (p of projects; track p.id) { +
    • + +

      {{ areaLabel(p.area) }} · {{ p.year }}

      +

      {{ p.title }}

      +

      {{ p.summary }}

      +
    • + } +
    +
    + + +
    +

    {{ home.partners.title }}

    +
      + @for (partner of home.partners.items; track partner) { +
    • {{ partner }}
    • + } +
    +
    + + +
    +
    +

    {{ home.reviews.title }}

    +
      + @for (review of home.reviews.items; track $index) { +
    • +
      +

      {{ review.text }}

      +
      +

      {{ review.author }}, {{ review.place }}

      +
    • + } +
    +
    +
    + + +
    + +
    + + +
    +
    +

    {{ home.contact.title }}

    +

    {{ home.contact.text }}

    + +
    +
    + @if (company.phone) { +
    Telefono
    +
    {{ company.phone }}
    + } + @if (company.email) { +
    Email
    +
    {{ company.email }}
    + } + @if (company.address; as a) { +
    Showroom
    +
    {{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }})
    + } +
    Orari
    + @for (line of company.openingHoursText; track line) { +
    {{ line }}
    + } +
    +
    diff --git a/apps/public/src/app/pages/home.scss b/apps/public/src/app/pages/home.scss new file mode 100644 index 0000000..4b283ed --- /dev/null +++ b/apps/public/src/app/pages/home.scss @@ -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; + } +} diff --git a/apps/public/src/app/pages/home.ts b/apps/public/src/app/pages/home.ts new file mode 100644 index 0000000..7c1f2a0 --- /dev/null +++ b/apps/public/src/app/pages/home.ts @@ -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)]) + } +} diff --git a/apps/public/src/app/pages/not-found.ts b/apps/public/src/app/pages/not-found.ts new file mode 100644 index 0000000..50a7691 --- /dev/null +++ b/apps/public/src/app/pages/not-found.ts @@ -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: ``, +}) +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, + }) + } +} diff --git a/apps/public/src/app/pages/order-confirmed.ts b/apps/public/src/app/pages/order-confirmed.ts new file mode 100644 index 0000000..fcb3f25 --- /dev/null +++ b/apps/public/src/app/pages/order-confirmed.ts @@ -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: ` + +
    +
    +
    +

    + Ordine n. {{ numero() }} confermato. + @if (last(); as l) { + Ti abbiamo inviato il riepilogo a {{ l.order.email }}. + } @else { + Riceverai a breve un'email con il riepilogo. + } +

    + + @if (last(); as l) { +

    Riepilogo

    +

    Effettuato il {{ date(l.order.createdAt) }}

    + + + + + + + + + + + @for (item of l.order.items; track $index) { + + + + + + } + + + + + + + + + + + + + + + + + + + +
    Prodotti ordinati
    ProdottoQuantitàTotale
    + {{ item.productTitle }} + @if (item.variantTitle && item.variantTitle !== 'Standard') { + — {{ item.variantTitle }} + } + {{ item.quantity }}{{ euro(item.total) }}
    Prodotti{{ euro(l.order.totals.items) }}
    Spedizione{{ euro(l.order.totals.shipping) }}
    Totale pagato{{ euro(l.order.totals.total) }}
    di cui IVA{{ euro(l.order.totals.tax) }}
    + } + +

    Torna allo shop

    +
    + + +
    +
    + `, + 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() + + private readonly config = inject(SITE_CONFIG) + private readonly session = inject(SessionService) + protected readonly last = signal(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())))) + } +} diff --git a/apps/public/src/app/pages/privacy.ts b/apps/public/src/app/pages/privacy.ts new file mode 100644 index 0000000..8dbf10d --- /dev/null +++ b/apps/public/src/app/pages/privacy.ts @@ -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: ` + +

    Vai alla cookie policy

    +
    + `, +}) +export default class PrivacyPage { + protected readonly page = PRIVACY_POLICY + + constructor() { + inject(SeoService).setPage({ title: this.page.title, description: this.page.description, path: '/privacy' }) + } +} diff --git a/apps/public/src/app/pages/product.ts b/apps/public/src/app/pages/product.ts new file mode 100644 index 0000000..c78ddeb --- /dev/null +++ b/apps/public/src/app/pages/product.ts @@ -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 = { + '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()) { + + } @else if (failed()) { + +
    +

    Lo shop non è raggiungibile in questo momento. Riprova tra qualche istante.

    +
    + } @else if (product(); as p) { + +
    +
    + + +
    + @if (variant(); as v) { +
    + @if (v.price !== null) { +

    + {{ euro(v.price) }} IVA inclusa +

    + @if (p.priceType === 'installer' && v.originalPrice !== null && v.originalPrice !== v.price) { +

    + Prezzo riservato installatori · listino {{ euro(v.originalPrice) }} +

    + } + } @else { +

    Prezzo su richiesta

    + } +

    {{ v.inStock ? 'Disponibile' : 'Non disponibile' }}

    + @if (v.sku) { +

    Codice: {{ v.sku }}

    + } +
    + +
    + @if (p.variants.length > 1) { +
    + Versione + @for (option of p.variants; track option.id) { + + } +
    + } + +
    + + +
    + + @if (addError(); as message) { + + } +
    + } +

    Hai bisogno di installazione o di un consiglio? Chiedi un preventivo

    +
    +
    + + @if (p.description) { +
    +

    Descrizione

    +

    {{ p.description }}

    +
    + } + + @if (documents().length) { +
    +

    Schede tecniche e documenti

    +
      + @for (d of documents(); track d.id) { +
    • + {{ d.title }} + {{ docType(d.mimeType) }} · {{ bytes(d.sizeBytes) }} +
    • + } +
    +
    + } +
    + } + `, + 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() + + 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(`/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>('/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({ 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(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 { + 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.') + } +} diff --git a/apps/public/src/app/pages/projects.ts b/apps/public/src/app/pages/projects.ts new file mode 100644 index 0000000..e84fd50 --- /dev/null +++ b/apps/public/src/app/pages/projects.ts @@ -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= + * (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: ` + + + +
    + + + + + +
    + `, + 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() + + 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', + }) + } +} diff --git a/apps/public/src/app/pages/shop.ts b/apps/public/src/app/pages/shop.ts new file mode 100644 index 0000000..3a68a35 --- /dev/null +++ b/apps/public/src/app/pages/shop.ts @@ -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()) { + + } @else { + +
    +
    + + @if (filters().length > 1) { + + } +
    + +

    {{ statusText() }}

    + + @if (failed()) { +

    Lo shop non è raggiungibile in questo momento. Riprova tra qualche istante.

    + } @else if (page(); as data) { + @if (data.items.length) { +

    Prodotti

    +
      + @for (product of data.items; track product.handle; let i = $index) { +
    • + } +
    + } @else { +
    + @if (q()) { +

    Nessun prodotto trovato per «{{ q() }}»{{ category() ? ' in ' + category()!.name : '' }}.

    +

    Prova con parole diverse o mostra tutti i prodotti.

    + } @else { +

    Non ci sono ancora prodotti{{ category() ? ' in questa categoria' : '' }}.

    +

    Chiedici un preventivo: ti aiutiamo a trovare la soluzione giusta.

    + } +
    + } + + } +
    + } + `, + 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() + /** Query params (?q=, ?pagina=). */ + readonly q = input('', { transform: (v: string | undefined) => normalizeQuery(v, SHOP_QUERY_MAX) }) + readonly pagina = input() + + 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('/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>('/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 | undefined; key: string }, Paginated | 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 }])) + } + }) + } +} diff --git a/apps/public/src/app/pages/showroom.html b/apps/public/src/app/pages/showroom.html new file mode 100644 index 0000000..0c9a929 --- /dev/null +++ b/apps/public/src/app/pages/showroom.html @@ -0,0 +1,194 @@ + + + + +
    +
    +
    +

    Vieni a trovarci

    + @for (p of content.intro; track $index) { +

    {{ p }}

    + } +
      + @for (h of content.highlights; track h.title) { +
    • + {{ h.title }} + {{ h.text }} +
    • + } +
    +

    Orari

    +
      + @for (line of company.openingHoursText; track line) { +
    • {{ line }}
    • + } +
    + @if (company.address; as a) { +

    {{ a.street }}, {{ a.postalCode }} {{ a.city }} ({{ a.province }}) — come raggiungerci

    + } +
    +
      + @for (photo of content.photos; track photo.src; let first = $first) { +
    • + +
    • + } +
    +
    + +
    + @if (booking(); as b) { +

    Visita prenotata

    +

    + Ti aspettiamo {{ visit(b.requestedAt) }} ({{ b.people }} {{ b.people === 1 ? 'persona' : 'persone' }}). + Ti abbiamo inviato una email di conferma a {{ b.email }}. +

    +

    Se non puoi più venire, rispondi all'email di conferma o scrivici.

    +

    + } @else if (received()) { +

    Richiesta ricevuta

    +

    Abbiamo ricevuto la tua richiesta: riceverai una email di conferma.

    + } @else { +

    Prenota una visita

    +

    Visita gratuita di circa un'ora con un nostro tecnico. I campi senza indicazione sono obbligatori.

    + @if (error(); as message) { + + } + +
    +
    + 1. Scegli giorno e orario + @switch (slotsState()) { + @case ('loading') { +

    Caricamento delle disponibilità…

    + } + @case ('error') { + + + } + @default { + @if (data(); as d) { +
    + + +
    + @if (d.days.length === 0) { +

    Nessuna data prenotabile in questo periodo.

    + } @else { +
      + @for (day of d.days; track day.date) { + @let free = freeSlots(day); +
    • + +
    • + } +
    + + @if (selectedDay(); as day) { +
    + Orari di {{ formatDay(day.date) }} +
    + @for (slot of day.slots; track slot.start) { + + } +
    +
    + } @else { +

    Nessun orario libero nelle date mostrate: prova con le date successive.

    + } + } + } + } + } + @if (slotError(); as message) { + + } +
    + +
    + 2. I tuoi dati +
    + + + +
    + + +
    +
    + +

    Ad esempio cosa ti interessa vedere: così prepariamo la visita.

    + +
    +
    + + +
    + +
    + @if (selectedSlot(); as s) { +

    Visita: {{ visit(s.start) }}

    + } + +
    +
    + } +
    +
    diff --git a/apps/public/src/app/pages/showroom.scss b/apps/public/src/app/pages/showroom.scss new file mode 100644 index 0000000..e669731 --- /dev/null +++ b/apps/public/src/app/pages/showroom.scss @@ -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; +} diff --git a/apps/public/src/app/pages/showroom.ts b/apps/public/src/app/pages/showroom.ts new file mode 100644 index 0000000..ee3121d --- /dev/null +++ b/apps/public/src/app/pages/showroom.ts @@ -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('loading') + protected readonly data = signal(null) + /** Primo giorno della pagina di calendario (null = da oggi). */ + private readonly from = signal(null) + private readonly history: (string | null)[] = [] + protected readonly canGoBack = signal(false) + protected readonly selectedDate = signal(null) + protected readonly selectedStart = signal(null) + + protected readonly selectedDay = computed(() => this.data()?.days.find((d) => d.date === this.selectedDate()) ?? null) + protected readonly selectedSlot = computed( + () => 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(null) + protected readonly error = signal(null) + protected readonly busy = signal(false) + protected readonly booking = signal(null) + /** Richiesta accettata senza dettagli (risposta 200 dell'API). */ + protected readonly received = signal(false) + private ticket: Promise | 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 { + this.slotsState.set('loading') + try { + const from = this.from() + const data = await firstValueFrom( + this.api.get('/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 { + 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 { + 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( + '/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(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()) +} diff --git a/apps/public/src/app/pages/solution-area.ts b/apps/public/src/app/pages/solution-area.ts new file mode 100644 index 0000000..8ec83be --- /dev/null +++ b/apps/public/src/app/pages/solution-area.ts @@ -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/.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) { + + + +
    + +
    +

    Le soluzioni

    + +
    +
    +
    + } + `, + 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() + 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)) + }) + } +} diff --git a/apps/public/src/app/pages/solution.ts b/apps/public/src/app/pages/solution.ts new file mode 100644 index 0000000..cfb2b53 --- /dev/null +++ b/apps/public/src/app/pages/solution.ts @@ -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//.ts. + * Coppie sconosciute → 404 (canMatch). + */ +@Component({ + selector: 'app-solution-page', + imports: [RouterLink, PageHeader, PlaceholderBadge, SolutionBody], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (data(); as d) { + + + +
    + + @if (siblings().length) { + + } +
    + } + `, + 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() + readonly soluzione = input.required() + 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)) + }) + } +} diff --git a/apps/public/src/app/shared/address-fields.ts b/apps/public/src/app/shared/address-fields.ts new file mode 100644 index 0000000..f11a78a --- /dev/null +++ b/apps/public/src/app/shared/address-fields.ts @@ -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; +
    + + + + + + + + + +
    +

    Spediamo solo in Italia.

    + `, + 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() + /** 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}` + } +} diff --git a/apps/public/src/app/shared/consent-fields.ts b/apps/public/src/app/shared/consent-fields.ts new file mode 100644 index 0000000..1344d08 --- /dev/null +++ b/apps/public/src/app/shared/consent-fields.ts @@ -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: ` +
    + Consensi + + @if (showError()) { +

    Il consenso è necessario per inviare la richiesta.

    + } + +
    + `, + styles: ` + .consents { + margin-top: var(--space-4); + } + `, +}) +export class ConsentFields { + readonly privacy = input.required>() + readonly marketing = input.required>() + /** "rispondere alla mia richiesta", "gestire la prenotazione"… */ + readonly purpose = input.required() + /** Errore visibile (dopo un tentativo di invio senza consenso). */ + readonly showError = input(false) + + protected readonly id = `consent-${++nextId}` +} + + diff --git a/apps/public/src/app/shared/faq-list.ts b/apps/public/src/app/shared/faq-list.ts new file mode 100644 index 0000000..4c08c4a --- /dev/null +++ b/apps/public/src/app/shared/faq-list.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import type { Faq } from '../../content/types' + +/** + * Domande frequenti come elenco espandibile (
    : 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) { +
    +

    Domande frequenti

    + @for (item of items(); track item.question) { +
    + {{ item.question }} +

    {{ item.answer }}

    +
    + } +
    + } + `, + 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() +} diff --git a/apps/public/src/app/shared/filter-nav.ts b/apps/public/src/app/shared/filter-nav.ts new file mode 100644 index 0000000..b21b467 --- /dev/null +++ b/apps/public/src/app/shared/filter-nav.ts @@ -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: ` + + `, + 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() + readonly label = input.required() + /** Query params conservati cambiando filtro (es. la ricerca). */ + readonly queryParams = input(null) +} diff --git a/apps/public/src/app/shared/form-field.ts b/apps/public/src/app/shared/form-field.ts new file mode 100644 index 0000000..2053d44 --- /dev/null +++ b/apps/public/src/app/shared/form-field.ts @@ -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 | 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: ` + + @if (hint()) { +

    {{ hint() }}

    + } + + @if (error(); as message) { +

    {{ message }}

    + } + `, + host: { class: 'form-field' }, +}) +export class FormField { + readonly control = input.required>() + readonly label = input.required() + readonly type = input<'text' | 'email' | 'tel'>('text') + readonly autocomplete = input(null) + readonly inputmode = input(null) + readonly maxlength = input(null) + readonly hint = input(null) + readonly required = input(true) + readonly submitted = input(false) + /** Messaggi per errori specifici (es. `{ pattern: 'CAP di 5 cifre' }`). */ + readonly messages = input>({}) + + 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()) + }) + } +} diff --git a/apps/public/src/app/shared/honeypot.ts b/apps/public/src/app/shared/honeypot.ts new file mode 100644 index 0000000..a0d4696 --- /dev/null +++ b/apps/public/src/app/shared/honeypot.ts @@ -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: ` + + `, + styles: ` + .hp { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; + } + `, +}) +export class Honeypot { + readonly value = model('') +} diff --git a/apps/public/src/app/shared/latest-posts.ts b/apps/public/src/app/shared/latest-posts.ts new file mode 100644 index 0000000..62fe751 --- /dev/null +++ b/apps/public/src/app/shared/latest-posts.ts @@ -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) { +
    +
    +

    {{ heading() }}

    + Tutti gli articoli +
    +
      + @for (post of posts(); track post.id) { +
    • + } +
    +
    + } + `, + 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>('/blog/posts', { query: { limit: params } }).pipe( + map((page) => page.items), + catchError(() => of([] as BlogPostSummaryDto[])), + ), + }) + + protected readonly posts = computed(() => (this.loaded.hasValue() ? (this.loaded.value() ?? []) : [])) +} diff --git a/apps/public/src/app/shared/leaflet-esm.d.ts b/apps/public/src/app/shared/leaflet-esm.d.ts new file mode 100644 index 0000000..3cac32d --- /dev/null +++ b/apps/public/src/app/shared/leaflet-esm.d.ts @@ -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' +} diff --git a/apps/public/src/app/shared/legal-page.ts b/apps/public/src/app/shared/legal-page.ts new file mode 100644 index 0000000..a4ef71d --- /dev/null +++ b/apps/public/src/app/shared/legal-page.ts @@ -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(); + +
    + @if (!p.validated) { +

    + DA VALIDARE CON CONSULENTE PRIVACY. Testo provvisorio: non costituisce ancora l'informativa + definitiva. +

    + } +

    Ultimo aggiornamento: {{ date(p.updatedAt) }}

    + @for (section of p.sections; track section.heading) { +
    +

    {{ section.heading }}

    + @for (paragraph of section.paragraphs; track $index) { +

    {{ paragraph }}

    + } + @if (section.items?.length) { +
      + @for (item of section.items; track $index) { +
    • {{ item }}
    • + } +
    + } +
    + } + +
    + `, + 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() + protected readonly date = (iso: string) => formatDate(`${iso}T12:00:00Z`) +} diff --git a/apps/public/src/app/shared/list-search.ts b/apps/public/src/app/shared/list-search.ts new file mode 100644 index 0000000..e4ef36f --- /dev/null +++ b/apps/public/src/app/shared/list-search.ts @@ -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: ` +
    + + +
    + `, + 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() + /** Ricerca attuale (già normalizzata, dall'URL). */ + readonly query = input.required() + /** 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 | undefined + + protected readonly text = linkedSignal({ + 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', + }) + } +} diff --git a/apps/public/src/app/shared/needs-picker.ts b/apps/public/src/app/shared/needs-picker.ts new file mode 100644 index 0000000..cdafb9e --- /dev/null +++ b/apps/public/src/app/shared/needs-picker.ts @@ -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: ` +
    +

    {{ heading() }}

    + @if (lead()) { +

    {{ lead() }}

    + } + +
    + `, + 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() + 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') +} diff --git a/apps/public/src/app/shared/not-found-view.ts b/apps/public/src/app/shared/not-found-view.ts new file mode 100644 index 0000000..5014885 --- /dev/null +++ b/apps/public/src/app/shared/not-found-view.ts @@ -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: ` + +

    + Puoi tornare alla home page, leggere gli articoli del blog oppure + contattarci. +

    +
    + `, +}) +export class NotFoundView { + readonly heading = input('Pagina non trovata') + readonly lead = input('La pagina che cerchi non esiste o è stata spostata.') +} diff --git a/apps/public/src/app/shared/osm-map.ts b/apps/public/src/app/shared/osm-map.ts new file mode 100644 index 0000000..836b1a5 --- /dev/null +++ b/apps/public/src/app/shared/osm-map.ts @@ -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: `` 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: `
    `, + 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() + readonly lng = input.required() + readonly label = input.required() + readonly zoom = input(15) + + private readonly document = inject(DOCUMENT) + private readonly host = viewChild.required>('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 { + 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: '© OpenStreetMap', + }).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 { + const existing = this.document.querySelector(`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) + } + }) + } +} diff --git a/apps/public/src/app/shared/page-header.ts b/apps/public/src/app/shared/page-header.ts new file mode 100644 index 0000000..ecfa8a0 --- /dev/null +++ b/apps/public/src/app/shared/page-header.ts @@ -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: ` +
    +
    + @if (crumbs().length) { + + } + +

    {{ heading() }}

    + @if (lead()) { +

    {{ lead() }}

    + } + +
    +
    + `, + 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() + readonly lead = input() + readonly crumbs = input([]) + /** Slug della macro-area: colora il bordo superiore. */ + readonly area = input() +} diff --git a/apps/public/src/app/shared/page-placeholder.ts b/apps/public/src/app/shared/page-placeholder.ts new file mode 100644 index 0000000..fdbaee2 --- /dev/null +++ b/apps/public/src/app/shared/page-placeholder.ts @@ -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: ` + +
    + + @if (showNotice()) { +

    Questa sezione è in preparazione: a breve troverai qui tutti i contenuti.

    + } +
    + `, + 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() + readonly lead = input.required() + readonly crumbs = input([]) + readonly showNotice = input(true) +} diff --git a/apps/public/src/app/shared/pagination.ts b/apps/public/src/app/shared/pagination.ts new file mode 100644 index 0000000..bcbf188 --- /dev/null +++ b/apps/public/src/app/shared/pagination.ts @@ -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) { + + } + `, + 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() + readonly q = input('') + readonly current = input.required() + readonly last = input.required() + readonly label = input('Pagine') + + protected readonly pages = computed(() => pageWindow(this.current(), this.last())) + protected readonly query = (page: number) => listQueryParams(this.q(), page) +} diff --git a/apps/public/src/app/shared/placeholder-badge.ts b/apps/public/src/app/shared/placeholder-badge.ts new file mode 100644 index 0000000..23d95e2 --- /dev/null +++ b/apps/public/src/app/shared/placeholder-badge.ts @@ -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) { +

    Testo segnaposto: da sostituire con i contenuti forniti dall'azienda

    + } + `, + 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() + protected readonly dev = isDevMode() +} diff --git a/apps/public/src/app/shared/post-card.ts b/apps/public/src/app/shared/post-card.ts new file mode 100644 index 0000000..8a3fc1e --- /dev/null +++ b/apps/public/src/app/shared/post-card.ts @@ -0,0 +1,88 @@ +import type { BlogPostSummaryDto } from '@iti/shared' +import { NgOptimizedImage } from '@angular/common' +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { RouterLink } from '@angular/router' +import { formatDate } from '../core/format' + +/** Card di un articolo (home, elenco del blog, articoli correlati). Va dentro un elemento `
  • `. */ +@Component({ + selector: 'app-post-card', + imports: [RouterLink, NgOptimizedImage], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @let p = post(); + @if (p.coverUrl) { +
    + +
    + } + @if (p.category) { +

    {{ p.category.name }}

    + } +

    {{ p.title }}

    + @if (p.excerpt) { +

    {{ p.excerpt }}

    + } + @if (p.publishedAt) { +

    + } + `, + styles: ` + :host { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + padding: var(--space-5); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + background: var(--color-surface); + } + .cover { + position: relative; + aspect-ratio: 16 / 9; + margin-bottom: var(--space-3); + overflow: hidden; + border-radius: var(--radius-md); + background: var(--color-surface-alt); + + img { + object-fit: cover; + } + } + .category { + margin-bottom: var(--space-2); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-secondary); + } + h3 { + font-size: var(--font-size-lg); + margin-bottom: var(--space-2); + } + /* Tutta la card è cliccabile, il link resta uno solo (nessun duplicato per gli screen reader) */ + a::after { + content: ''; + position: absolute; + inset: 0; + } + .excerpt { + color: var(--color-ink-muted); + font-size: var(--font-size-sm); + } + .date { + margin: auto 0 0; + font-size: var(--font-size-xs); + color: var(--color-ink-muted); + } + `, +}) +export class PostCard { + readonly post = input.required() + /** Immagine caricata subito (prime card visibili sopra la piega). */ + readonly priority = input(false) + + protected readonly date = formatDate +} diff --git a/apps/public/src/app/shared/product-card.ts b/apps/public/src/app/shared/product-card.ts new file mode 100644 index 0000000..8e580d6 --- /dev/null +++ b/apps/public/src/app/shared/product-card.ts @@ -0,0 +1,127 @@ +import type { ShopProductSummaryDto } from '@iti/shared' +import { NgOptimizedImage } from '@angular/common' +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { RouterLink } from '@angular/router' +import { formatEuro } from '../core/format' +import { productPath } from '../core/shop' + +/** + * Card di un prodotto (shop, prodotti correlati delle soluzioni). Va dentro un `
  • `. Prezzo IVA inclusa; + * "da" se le varianti hanno prezzi diversi; etichetta per il prezzo riservato agli installatori. + */ +@Component({ + selector: 'app-product-card', + imports: [RouterLink, NgOptimizedImage], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @let p = product(); +
    + @if (p.thumbnail) { + + } +
    + @if (p.categories.length) { +

    {{ p.categories[0]!.name }}

    + } +

    {{ p.title }}

    + @if (p.subtitle) { +

    {{ p.subtitle }}

    + } +
    + @if (p.fromPrice !== null) { +

    + @if (fromLabel()) { + da + } + {{ euro(p.fromPrice) }} IVA inclusa +

    + @if (p.priceType === 'installer') { +

    Prezzo riservato installatori

    + } + } @else { +

    Prezzo su richiesta

    + } +
    + `, + styles: ` + :host { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + padding: var(--space-4); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + background: var(--color-surface); + } + .thumb { + position: relative; + aspect-ratio: 4 / 3; + margin-bottom: var(--space-3); + overflow: hidden; + border-radius: var(--radius-md); + background: var(--color-surface-alt); + + img { + object-fit: contain; + } + } + .category { + 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); + } + h3 { + font-size: var(--font-size-lg); + margin-bottom: var(--space-2); + } + /* Tutta la card è cliccabile con un solo link */ + a::after { + content: ''; + position: absolute; + inset: 0; + } + .subtitle { + color: var(--color-ink-muted); + font-size: var(--font-size-sm); + } + .bottom { + margin-top: auto; + + p { + margin: 0; + } + } + .price { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-primary); + } + .from, + .vat { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + color: var(--color-ink-muted); + } + .reserved { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-secondary); + } + .na { + color: var(--color-ink-muted); + } + `, +}) +export class ProductCard { + readonly product = input.required() + readonly priority = input(false) + /** "da" davanti al prezzo (prodotti con più varianti a prezzi diversi). */ + readonly fromLabel = input(false) + + protected readonly euro = formatEuro + protected readonly path = productPath +} diff --git a/apps/public/src/app/shared/quote-cta.ts b/apps/public/src/app/shared/quote-cta.ts new file mode 100644 index 0000000..e2f1c20 --- /dev/null +++ b/apps/public/src/app/shared/quote-cta.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core' +import { RouterLink } from '@angular/router' +import type { ConfiguratorPath } from '../../content/types' + +/** + * Invito al preventivo: "Richiedi un preventivo" porta ai contatti con oggetto precompilato + * (/contatti?oggetto=preventivo&soluzione=, letto dal form del prompt 28); se c'è un percorso + * del configuratore adatto, aggiunge "Calcola una stima" (/configuratore?percorso=); con `showConfigurator` + * il link c'è comunque, senza percorso preselezionato. + */ +@Component({ + selector: 'app-quote-cta', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +
    +

    {{ heading() }}

    +

    {{ text() }}

    +
    +
    + Richiedi un preventivo + @if (configuratorPath(); as path) { + Calcola una stima + } @else if (showConfigurator()) { + Calcola una stima + } +
    +
    + `, + styles: ` + @use 'mixins' as mq; + + .cta { + display: grid; + gap: var(--space-5); + align-items: center; + margin-block: var(--space-7); + padding: var(--space-6) var(--space-5); + border-radius: var(--radius-lg); + background: var(--color-primary); + color: var(--color-on-primary); + + @include mq.up(md) { + grid-template-columns: 1fr auto; + padding: var(--space-7); + } + } + h2 { + color: inherit; + font-size: var(--font-size-2xl); + } + p { + margin: 0; + color: #e3eef2; + } + .actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + } + .btn--outline { + color: var(--color-on-primary); + } + `, +}) +export class QuoteCta { + /** Slug della soluzione o dell'area (finisce nel parametro "soluzione"). */ + readonly topic = input() + readonly configuratorPath = input() + readonly showConfigurator = input(false) + readonly heading = input('Parliamo del tuo progetto') + readonly text = input('Sopralluogo e preventivo senza impegno: ti ricontattiamo entro un giorno lavorativo.') + + protected readonly contactQuery = computed(() => { + const topic = this.topic() + return topic ? { oggetto: 'preventivo', soluzione: topic } : { oggetto: 'preventivo' } + }) +} diff --git a/apps/public/src/app/shared/related-products.ts b/apps/public/src/app/shared/related-products.ts new file mode 100644 index 0000000..6cca509 --- /dev/null +++ b/apps/public/src/app/shared/related-products.ts @@ -0,0 +1,78 @@ +import type { ShopProductDetailDto } from '@iti/shared' +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { catchError, forkJoin, map, of } from 'rxjs' +import { ApiService } from '../core/api.service' +import { SessionService } from '../core/session.service' +import { ProductCard } from './product-card' + +/** + * Prodotti correlati: per ogni handle Medusa carica la scheda da GET /shop/products/:handle e mostra una card. + * Handle inesistenti o API non raggiungibile → la card (o l'intera sezione) viene omessa, senza errori in pagina. + * Le richieste fatte durante l'SSR vengono riusate dal browser (transfer cache). + */ +@Component({ + selector: 'app-related-products', + imports: [ProductCard], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (products().length) { + + } + `, + styles: ` + @use 'mixins' as mq; + + .related { + margin-block: var(--space-7); + } + h2 { + font-size: var(--font-size-2xl); + } + ul { + 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); + } + } + `, +}) +export class RelatedProducts { + readonly handles = input.required() + readonly heading = input('Prodotti correlati') + + private readonly api = inject(ApiService) + private readonly session = inject(SessionService) + + private readonly loaded = rxResource({ + // priceKey: per un installatore le card si ricaricano con i prezzi riservati + params: () => ({ handles: this.handles(), prices: this.session.priceKey() }), + stream: ({ params }) => + params.handles.length + ? forkJoin( + params.handles.map((handle) => + this.api.get(`/shop/products/${encodeURIComponent(handle)}`, { auth: true }).pipe(catchError(() => of(null))), + ), + ).pipe(map((list) => list.filter((p): p is ShopProductDetailDto => p !== null))) + : of([]), + }) + + protected readonly products = computed(() => (this.loaded.hasValue() ? (this.loaded.value() ?? []) : [])) + protected readonly hasPriceRange = (p: ShopProductDetailDto) => + new Set(p.variants.map((v) => v.price).filter((price) => price !== null)).size > 1 +} diff --git a/apps/public/src/app/shared/solution-body.ts b/apps/public/src/app/shared/solution-body.ts new file mode 100644 index 0000000..e47a99c --- /dev/null +++ b/apps/public/src/app/shared/solution-body.ts @@ -0,0 +1,161 @@ +import { NgOptimizedImage } from '@angular/common' +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import type { AreaContent, SolutionContent } from '../../content/types' +import { FaqList } from './faq-list' +import { QuoteCta } from './quote-cta' +import { RelatedProducts } from './related-products' + +/** + * Corpo comune di macro-area e soluzione, generato dal file di contenuto: introduzione con immagine, + * vantaggi, "come funziona", contenuto extra (ng-content, es. elenco soluzioni), prodotti correlati, FAQ, CTA. + */ +@Component({ + selector: 'app-solution-body', + imports: [NgOptimizedImage, FaqList, QuoteCta, RelatedProducts], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @let c = content(); +
    +
    + @for (p of c.intro; track $index) { +

    {{ p }}

    + } +
    + +
    + + @if (c.benefits.length) { +
    +

    Vantaggi

    +
      + @for (b of c.benefits; track b.title) { +
    • +

      {{ b.title }}

      +

      {{ b.text }}

      +
    • + } +
    +
    + } + + @if (c.steps.length) { +
    +

    Come funziona

    +
      + @for (s of c.steps; track s.title) { +
    1. +

      {{ s.title }}

      +

      {{ s.text }}

      +
    2. + } +
    +
    + } + + + + + + @if (c.configuratorPath) { + + } @else { + + } + `, + styles: ` + @use 'mixins' as mq; + + :host { + display: block; + } + section { + margin-block: var(--space-7); + } + h2 { + font-size: var(--font-size-2xl); + } + .intro { + display: grid; + gap: var(--space-5); + align-items: center; + + @include mq.up(md) { + grid-template-columns: 3fr 2fr; + gap: var(--space-7); + } + p { + font-size: var(--font-size-lg); + max-width: 65ch; + } + } + .image { + width: 100%; + height: auto; + border-radius: var(--radius-lg); + } + .benefits { + 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(4, 1fr); + } + li { + padding: var(--space-5); + border-radius: var(--radius-lg); + background: var(--color-surface-alt); + } + } + h3 { + font-size: var(--font-size-lg); + margin-bottom: var(--space-2); + } + li p { + margin: 0; + color: var(--color-ink-muted); + } + .steps { + display: grid; + gap: var(--space-4); + margin: 0; + padding: 0; + list-style: none; + counter-reset: step; + + @include mq.up(md) { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + li { + position: relative; + padding: var(--space-5) var(--space-5) var(--space-5) calc(var(--space-5) + 2.75rem); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + counter-increment: step; + + &::before { + content: counter(step); + position: absolute; + left: var(--space-4); + top: var(--space-5); + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-on-primary); + font-weight: var(--font-weight-bold); + } + } + } + `, +}) +export class SolutionBody { + readonly content = input.required() +} diff --git a/apps/public/src/content/areas.ts b/apps/public/src/content/areas.ts new file mode 100644 index 0000000..5798bce --- /dev/null +++ b/apps/public/src/content/areas.ts @@ -0,0 +1,9 @@ +import { AREA_FILES } from './generated/areas' +import type { AreaContent } from './types' + +/** Macro-aree ordinate per `order` (menu, footer, home). Caricate con il bundle iniziale: usate dall'header. */ +export const AREAS: readonly AreaContent[] = [...AREA_FILES].sort((a, b) => a.order - b.order) + +export function findArea(slug: string): AreaContent | undefined { + return AREAS.find((a) => a.slug === slug) +} diff --git a/apps/public/src/content/areas/acqua.ts b/apps/public/src/content/areas/acqua.ts new file mode 100644 index 0000000..23d504e --- /dev/null +++ b/apps/public/src/content/areas/acqua.ts @@ -0,0 +1,49 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineArea } from '../types' + +export default defineArea({ + placeholder: true, + slug: 'acqua', + label: 'Acqua', + order: 1, + title: "Trattamento dell'acqua", + tagline: "Addolcitori, filtrazione e osmosi inversa per un'acqua migliore in casa e in azienda.", + description: + "Addolcitori, filtri e impianti a osmosi inversa: analisi gratuita dell'acqua, installazione e manutenzione per casa e azienda.", + intro: [ + "L'acqua di rete è sicura, ma spesso è dura o porta con sé sedimenti e cloro: il calcare si deposita in tubazioni, caldaie ed elettrodomestici, ne riduce l'efficienza e ne accorcia la vita.", + "Partiamo da un'analisi dell'acqua sul posto e proponiamo il trattamento adatto: addolcimento, filtrazione o osmosi inversa per l'acqua da bere, con installazione, assistenza e manutenzione programmata.", + ], + image: { src: '/images/aree/acqua.svg', alt: "Illustrazione: goccia d'acqua e tubazioni", width: 1200, height: 800 }, + benefits: [ + { title: 'Meno calcare', text: 'Protegge caldaia, scaldabagno, lavatrice e lavastoviglie dalle incrostazioni.' }, + { title: 'Risparmio', text: 'Meno detersivi e manutenzioni, impianti più efficienti nel tempo.' }, + { title: 'Acqua buona da bere', text: "Con l'osmosi inversa niente più bottiglie di plastica da comprare e trasportare." }, + { title: 'Assistenza vicina', text: 'Tecnici sul territorio per installazione, ricariche e manutenzione.' }, + ], + steps: [ + { title: "Analisi dell'acqua", text: 'Misuriamo durezza e parametri principali direttamente a casa tua.' }, + { title: 'Proposta su misura', text: "Scegliamo il trattamento e la taglia in base all'acqua e ai consumi della famiglia." }, + { title: 'Installazione', text: 'Montaggio a regola d’arte e collaudo, in genere in mezza giornata.' }, + { title: 'Manutenzione', text: 'Controlli periodici e sanificazione per mantenere le prestazioni.' }, + ], + relatedProducts: ['addolcitore-domestico-20l'], + faq: [ + { + question: "Come capisco se l'acqua di casa è dura?", + answer: + "La durezza si misura in gradi francesi (°f) ed è indicata nella bolletta o sul sito del gestore. Sopra i 25-30 °f l'acqua è considerata dura e conviene un addolcitore. Possiamo misurarla gratuitamente durante il sopralluogo.", + }, + { + question: "L'acqua addolcita si può bere?", + answer: + "Sì, se l'addolcitore è regolato correttamente: la normativa prevede di lasciare una durezza residua minima e il nostro tecnico tara l'impianto di conseguenza.", + }, + { + question: 'Ogni quanto serve la manutenzione?', + answer: + 'Consigliamo un controllo con sanificazione una volta all’anno; il sale per la rigenerazione va aggiunto quando il serbatoio si svuota, in genere ogni 1-2 mesi.', + }, + ], + configuratorPath: 'acqua', +}) diff --git a/apps/public/src/content/areas/comfort.ts b/apps/public/src/content/areas/comfort.ts new file mode 100644 index 0000000..4ee7f01 --- /dev/null +++ b/apps/public/src/content/areas/comfort.ts @@ -0,0 +1,48 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineArea } from '../types' + +export default defineArea({ + placeholder: true, + slug: 'comfort', + label: 'Comfort', + order: 3, + title: 'Comfort e climatizzazione', + tagline: 'Pompe di calore e climatizzazione efficienti per riscaldare e raffrescare con meno energia.', + description: + 'Pompe di calore aria-acqua e climatizzatori ad alta efficienza: dimensionamento, installazione e assistenza per casa e ufficio.', + intro: [ + "Le pompe di calore prendono gran parte dell'energia dall'aria esterna: per ogni kWh elettrico restituiscono 3-4 kWh di calore, e d'estate possono raffrescare.", + "Le dimensioniamo sulla casa reale (superficie, isolamento, terminali) e, abbinate al fotovoltaico, riducono al minimo i costi di riscaldamento.", + ], + image: { src: '/images/aree/comfort.svg', alt: 'Illustrazione: casa con pompa di calore', width: 1200, height: 800 }, + benefits: [ + { title: 'Meno costi di riscaldamento', text: 'Rispetto a gasolio, GPL o vecchie caldaie il risparmio è spesso significativo.' }, + { title: 'Caldo e fresco', text: 'Un solo impianto per riscaldamento, raffrescamento e acqua calda sanitaria.' }, + { title: 'Senza combustione', text: 'Niente fiamma né fumi in casa, meno emissioni.' }, + { title: 'Perfetta col fotovoltaico', text: "Usa l'energia del tuo tetto per scaldare la casa." }, + ], + steps: [ + { title: 'Sopralluogo', text: 'Valutiamo superficie, isolamento, impianto esistente e terminali.' }, + { title: 'Dimensionamento', text: 'Calcoliamo la potenza corretta: né troppo piccola né sovradimensionata.' }, + { title: 'Installazione', text: 'Sostituzione del generatore e adeguamento dell’impianto.' }, + { title: 'Assistenza', text: 'Prima accensione, taratura e manutenzione periodica.' }, + ], + relatedProducts: ['pompa-di-calore-aria-acqua-8kw'], + faq: [ + { + question: 'La pompa di calore funziona con i termosifoni?', + answer: + 'Sì, con alcune attenzioni: rende al meglio con impianti a bassa temperatura (pavimento, ventilconvettori), ma i modelli attuali lavorano bene anche con radiatori dimensionati correttamente. Lo verifichiamo al sopralluogo.', + }, + { + question: "Funziona anche quando fuori fa molto freddo?", + answer: + 'Le pompe di calore moderne funzionano anche sotto zero; il rendimento cala con temperature molto basse, per questo il dimensionamento tiene conto del clima della tua zona.', + }, + { + question: 'Che manutenzione richiede?', + answer: 'Una manutenzione annuale con pulizia dei filtri e controllo del circuito, più la verifica prevista dalla normativa sugli impianti termici.', + }, + ], + configuratorPath: 'pompa_calore', +}) diff --git a/apps/public/src/content/areas/energia.ts b/apps/public/src/content/areas/energia.ts new file mode 100644 index 0000000..f0ccb12 --- /dev/null +++ b/apps/public/src/content/areas/energia.ts @@ -0,0 +1,49 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineArea } from '../types' + +export default defineArea({ + placeholder: true, + slug: 'energia', + label: 'Energia', + order: 2, + title: 'Fotovoltaico ed energia rinnovabile', + tagline: 'Impianti fotovoltaici e sistemi di accumulo per produrre e usare la tua energia.', + description: + 'Impianti fotovoltaici chiavi in mano e batterie di accumulo: progettazione, pratiche, installazione e monitoraggio per abbattere la bolletta.', + intro: [ + "Un impianto fotovoltaico trasforma il tetto in una piccola centrale: l'energia prodotta di giorno alimenta la casa e, con una batteria, anche la sera.", + "Seguiamo tutto il percorso: sopralluogo, progetto, pratiche con il gestore di rete, installazione, collaudo e monitoraggio della produzione nel tempo.", + ], + image: { src: '/images/aree/energia.svg', alt: 'Illustrazione: pannelli fotovoltaici sotto il sole', width: 1200, height: 800 }, + benefits: [ + { title: 'Bolletta più leggera', text: "Ogni kWh autoprodotto e consumato è un kWh che non compri dalla rete." }, + { title: 'Energia anche la sera', text: "Con l'accumulo usi dopo il tramonto l'energia prodotta di giorno." }, + { title: 'Chiavi in mano', text: 'Progetto, pratiche e connessione: un unico referente dall’inizio alla fine.' }, + { title: 'Produzione sotto controllo', text: "App di monitoraggio e assistenza in caso di anomalie." }, + ], + steps: [ + { title: 'Sopralluogo', text: 'Verifichiamo tetto, orientamento, ombreggiamenti e consumi in bolletta.' }, + { title: 'Progetto e preventivo', text: 'Dimensioniamo impianto e batteria sui tuoi consumi, con stima del rientro.' }, + { title: 'Pratiche e installazione', text: 'Gestiamo le pratiche e installiamo l’impianto in pochi giorni.' }, + { title: 'Connessione e monitoraggio', text: "Attivazione con il gestore e controllo della produzione da app." }, + ], + relatedProducts: ['kit-fotovoltaico-6kw-accumulo'], + faq: [ + { + question: 'Quanto è grande un impianto per una famiglia?', + answer: + 'Dipende dai consumi: per una famiglia con 3.000-4.500 kWh all’anno in genere bastano 3-6 kW, cioè circa 15-30 m² di tetto. Il configuratore ti dà una prima stima in pochi minuti.', + }, + { + question: 'Conviene aggiungere la batteria?', + answer: + "Conviene se consumi molta energia la sera o se sei fuori casa di giorno: la batteria aumenta la quota di energia autoconsumata. Ti mostriamo il confronto con e senza accumulo nel preventivo.", + }, + { + question: 'Ci sono incentivi?', + answer: + 'Gli incentivi e le detrazioni cambiano nel tempo: al momento del preventivo verifichiamo quelli in vigore e ti indichiamo come accedervi.', + }, + ], + configuratorPath: 'fotovoltaico', +}) diff --git a/apps/public/src/content/areas/industria.ts b/apps/public/src/content/areas/industria.ts new file mode 100644 index 0000000..8002bbf --- /dev/null +++ b/apps/public/src/content/areas/industria.ts @@ -0,0 +1,43 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineArea } from '../types' + +export default defineArea({ + placeholder: true, + slug: 'industria', + label: 'Industria', + order: 5, + title: 'Soluzioni industriali', + tagline: 'Trattamento acque di processo e fotovoltaico industriale per ridurre costi e consumi.', + description: + 'Soluzioni per le imprese: trattamento delle acque di processo, fotovoltaico su capannoni ed efficienza energetica, con consulenza dedicata.', + intro: [ + "Per un'azienda acqua ed energia sono costi di produzione: un trattamento adeguato protegge macchinari e processi, un impianto fotovoltaico taglia la bolletta per vent'anni.", + 'Lavoriamo con un tecnico dedicato: analisi dei consumi e dei processi, progetto, installazione con fermi produttivi ridotti al minimo e assistenza programmata.', + ], + image: { src: '/images/aree/industria.svg', alt: 'Illustrazione: capannone industriale con pannelli solari', width: 1200, height: 800 }, + benefits: [ + { title: 'Costi energetici più bassi', text: 'Autoproduzione sui tetti dei capannoni, dove i consumi sono di giorno.' }, + { title: 'Processi protetti', text: 'Acqua trattata per caldaie, torri evaporative, lavaggi e linee produttive.' }, + { title: 'Un referente unico', text: 'Un tecnico dedicato dalla diagnosi alla manutenzione.' }, + { title: 'Sostenibilità misurabile', text: 'Dati di produzione e consumo utili anche per i report ESG.' }, + ], + steps: [ + { title: 'Diagnosi', text: 'Analisi dei consumi, delle acque e dei processi produttivi.' }, + { title: 'Progetto', text: 'Soluzione tecnica, piano economico e tempi di intervento.' }, + { title: 'Realizzazione', text: 'Installazione pianificata per ridurre i fermi produttivi.' }, + { title: 'Gestione', text: 'Monitoraggio e manutenzione programmata.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Lavorate anche su impianti esistenti?', + answer: 'Sì: possiamo potenziare o revisionare impianti di trattamento e fotovoltaici esistenti, anche installati da altri.', + }, + { + question: 'Quanto tempo richiede un impianto fotovoltaico industriale?', + answer: + 'Dipende dalla taglia e dalle autorizzazioni: dopo il sopralluogo ti diamo un cronoprogramma con le tempistiche di pratiche, installazione e connessione.', + }, + ], + configuratorPath: 'azienda', +}) diff --git a/apps/public/src/content/areas/mobilita.ts b/apps/public/src/content/areas/mobilita.ts new file mode 100644 index 0000000..af28fb0 --- /dev/null +++ b/apps/public/src/content/areas/mobilita.ts @@ -0,0 +1,42 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineArea } from '../types' + +export default defineArea({ + placeholder: true, + slug: 'mobilita', + label: 'Mobilità', + order: 4, + title: 'Mobilità elettrica', + tagline: 'Wallbox e stazioni di ricarica per auto elettriche, a casa e in azienda.', + description: + 'Wallbox domestiche e stazioni di ricarica aziendali: verifica dell’impianto, installazione certificata e integrazione con il fotovoltaico.', + intro: [ + "Ricaricare l'auto elettrica a casa è più comodo ed economico che alle colonnine pubbliche, soprattutto se l'energia viene dal tuo impianto fotovoltaico.", + "Verifichiamo la potenza disponibile, installiamo la wallbox con le protezioni previste e, se serve, la colleghiamo al fotovoltaico per ricaricare con il sole.", + ], + image: { src: '/images/aree/mobilita.svg', alt: 'Illustrazione: auto elettrica in ricarica', width: 1200, height: 800 }, + benefits: [ + { title: 'Ricarica di notte o col sole', text: 'Programmi la ricarica quando l’energia costa meno o la produci tu.' }, + { title: 'Sicura e certificata', text: 'Installazione a norma con protezioni dedicate e dichiarazione di conformità.' }, + { title: 'Niente distacchi', text: 'La gestione dinamica della potenza evita che salti il contatore.' }, + { title: 'Anche per le aziende', text: 'Stazioni per flotte, dipendenti e clienti, con gestione degli accessi.' }, + ], + steps: [ + { title: 'Verifica dell’impianto', text: 'Controlliamo contatore, potenza disponibile e percorso dei cavi.' }, + { title: 'Scelta della wallbox', text: 'Potenza e funzioni in base all’auto e alle abitudini di ricarica.' }, + { title: 'Installazione', text: 'Posa, protezioni, collaudo e configurazione dell’app.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Che potenza serve per la wallbox?', + answer: + 'Con un contatore domestico da 3-4,5 kW si ricarica comunque bene di notte; per ricariche più rapide si può aumentare la potenza del contatore. Valutiamo insieme la soluzione più conveniente.', + }, + { + question: 'Posso installarla in un condominio?', + answer: + 'Sì: nel box o nel posto auto di proprietà la normativa consente l’installazione dandone comunicazione all’amministratore. Ci occupiamo noi della parte tecnica.', + }, + ], +}) diff --git a/apps/public/src/content/catalog.ts b/apps/public/src/content/catalog.ts new file mode 100644 index 0000000..3057ae0 --- /dev/null +++ b/apps/public/src/content/catalog.ts @@ -0,0 +1,21 @@ +import { findArea } from './areas' +import { SOLUTION_FILES } from './generated/solutions' +import type { AreaContent, SolutionContent } from './types' + +/** + * Soluzioni (src/content/solutions//.ts). Importato solo dalle pagine (chunk lazy), + * non dal bundle iniziale. + */ +export const SOLUTIONS: readonly SolutionContent[] = [...SOLUTION_FILES].sort( + (a, b) => a.area.localeCompare(b.area) || a.order - b.order, +) + +export function solutionsOf(area: string): SolutionContent[] { + return SOLUTIONS.filter((s) => s.area === area) +} + +export function findSolution(areaSlug: string, slug: string): { area: AreaContent; solution: SolutionContent } | undefined { + const area = findArea(areaSlug) + const solution = SOLUTIONS.find((s) => s.area === areaSlug && s.slug === slug) + return area && solution ? { area, solution } : undefined +} diff --git a/apps/public/src/content/company-pages.ts b/apps/public/src/content/company-pages.ts new file mode 100644 index 0000000..1734222 --- /dev/null +++ b/apps/public/src/content/company-pages.ts @@ -0,0 +1,14 @@ +import { COMPANY_PAGE_FILES } from './generated/company' +import type { CompanyPageContent } from './types' + +/** Pagine azienda ordinate per `order`; la prima (chi siamo) è servita su /azienda. */ +export const COMPANY_PAGES: readonly CompanyPageContent[] = [...COMPANY_PAGE_FILES].sort((a, b) => a.order - b.order) + +export function findCompanyPage(slug: string): CompanyPageContent | undefined { + return COMPANY_PAGES.find((p) => p.slug === slug) +} + +/** URL della pagina: la prima è /azienda. */ +export function companyPagePath(page: CompanyPageContent): string { + return page === COMPANY_PAGES[0] ? '/azienda' : `/azienda/${page.slug}` +} diff --git a/apps/public/src/content/company.ts b/apps/public/src/content/company.ts new file mode 100644 index 0000000..74f3562 --- /dev/null +++ b/apps/public/src/content/company.ts @@ -0,0 +1,21 @@ +/** + * Dati aziendali per footer, home (blocco contatti) e JSON-LD. ⚠️ DA COMPLETARE con i dati reali (ragione sociale, + * P.IVA, sede, contatti): i campi null vengono omessi dal footer e dallo structured data, mai inventati. + */ +export const COMPANY = { + name: 'ITI TECHNOLOGY', + legalName: null as string | null, + vatNumber: null as string | null, + description: + 'Trattamento acqua, fotovoltaico ed energia rinnovabile, pompe di calore e climatizzazione, mobilità elettrica e soluzioni industriali.', + email: null as string | null, + phone: null as string | null, + address: null as { street: string; postalCode: string; city: string; province: string; country: 'IT' } | null, + geo: null as { lat: number; lng: number } | null, + /** Orari dello showroom in formato schema.org; allineati a apps/api/src/showroom/config.ts. */ + openingHours: ['Mo-Fr 09:00-12:00', 'Mo-Fr 14:00-18:00', 'Sa 09:00-12:00'], + /** Orari in forma leggibile (blocco contatti). */ + openingHoursText: ['Lunedì-venerdì 9-12 e 14-18', 'Sabato 9-12'], + /** Profili social (URL), per sameAs. */ + sameAs: [] as string[], +} as const diff --git a/apps/public/src/content/company/certificazioni.ts b/apps/public/src/content/company/certificazioni.ts new file mode 100644 index 0000000..0eeacd9 --- /dev/null +++ b/apps/public/src/content/company/certificazioni.ts @@ -0,0 +1,26 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineCompanyPage } from '../types' + +export default defineCompanyPage({ + placeholder: true, + slug: 'certificazioni', + order: 2, + title: 'Certificazioni', + description: 'Certificazioni, abilitazioni e qualifiche dei tecnici ITI TECHNOLOGY per impianti elettrici, termici, fotovoltaici e trattamento acqua.', + lead: 'Impianti realizzati da tecnici abilitati, secondo le norme vigenti.', + sections: [ + { + heading: 'Abilitazioni', + paragraphs: ['[Segnaposto] Elenco delle abilitazioni effettivamente possedute dall’azienda, da verificare prima della pubblicazione.'], + items: [ + '[Segnaposto] Abilitazione impianti elettrici', + '[Segnaposto] Abilitazione impianti termici e idraulici', + '[Segnaposto] Certificazione per gas fluorurati (climatizzazione e pompe di calore)', + ], + }, + { + heading: 'Documentazione', + paragraphs: ['A fine lavori rilasciamo la dichiarazione di conformità e la documentazione tecnica dell’impianto.'], + }, + ], +}) diff --git a/apps/public/src/content/company/chi-siamo.ts b/apps/public/src/content/company/chi-siamo.ts new file mode 100644 index 0000000..1070d2d --- /dev/null +++ b/apps/public/src/content/company/chi-siamo.ts @@ -0,0 +1,30 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineCompanyPage } from '../types' + +export default defineCompanyPage({ + placeholder: true, + slug: 'chi-siamo', + order: 1, + title: 'Chi siamo', + description: 'ITI TECHNOLOGY: tecnici specializzati in trattamento acqua, fotovoltaico, pompe di calore e mobilità elettrica. La nostra storia e il nostro metodo.', + lead: 'Un unico partner per acqua, energia e comfort: dalla consulenza all’assistenza nel tempo.', + sections: [ + { + heading: 'La nostra storia', + paragraphs: [ + '[Segnaposto] Qui andrà la storia dell’azienda: anno di fondazione, territorio, crescita e tappe principali.', + 'Siamo partiti dal trattamento dell’acqua e negli anni abbiamo aggiunto fotovoltaico, pompe di calore e mobilità elettrica, per offrire soluzioni integrate.', + ], + }, + { + heading: 'Il nostro metodo', + paragraphs: ['Ogni progetto parte da un sopralluogo e da numeri reali: consumi, caratteristiche dell’acqua, dell’edificio e delle abitudini di chi lo vive.'], + items: ['Sopralluogo e analisi gratuiti', 'Preventivo chiaro, con stima dei risparmi', 'Installazione con personale qualificato', 'Assistenza e manutenzione programmata'], + }, + { + heading: 'Le persone', + paragraphs: ['[Segnaposto] Presentazione del team: tecnici, progettisti, ufficio commerciale e assistenza.'], + }, + ], + cta: { label: 'Contattaci', path: '/contatti' }, +}) diff --git a/apps/public/src/content/company/lavora-con-noi.ts b/apps/public/src/content/company/lavora-con-noi.ts new file mode 100644 index 0000000..de65d13 --- /dev/null +++ b/apps/public/src/content/company/lavora-con-noi.ts @@ -0,0 +1,23 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineCompanyPage } from '../types' + +export default defineCompanyPage({ + placeholder: true, + slug: 'lavora-con-noi', + order: 4, + title: 'Lavora con noi', + description: 'Posizioni aperte in ITI TECHNOLOGY: tecnici installatori, progettisti e assistenza. Invia la tua candidatura.', + lead: 'Cerchiamo persone che vogliono lavorare sulle tecnologie della transizione energetica.', + sections: [ + { + heading: 'Chi cerchiamo', + paragraphs: ['[Segnaposto] Posizioni aperte, requisiti e sede di lavoro, da aggiornare a cura dell’azienda.'], + items: ['Tecnici installatori (idraulica, elettrica)', 'Progettisti impianti fotovoltaici e termici', 'Addetti all’assistenza clienti'], + }, + { + heading: 'Come candidarsi', + paragraphs: ['Scrivici dalla pagina contatti indicando la posizione di interesse: ti risponderemo per un primo colloquio.'], + }, + ], + cta: { label: 'Invia la candidatura', path: '/contatti', query: { oggetto: 'candidatura' } }, +}) diff --git a/apps/public/src/content/company/partner.ts b/apps/public/src/content/company/partner.ts new file mode 100644 index 0000000..b1a64c1 --- /dev/null +++ b/apps/public/src/content/company/partner.ts @@ -0,0 +1,28 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +// I marchi reali vanno in src/content/home.ts → partners (nome e logo, con autorizzazione all'uso). +import { defineCompanyPage } from '../types' + +export default defineCompanyPage({ + placeholder: true, + slug: 'partner', + order: 3, + title: 'Partner', + description: 'I marchi e i partner con cui ITI TECHNOLOGY lavora per trattamento acqua, fotovoltaico, pompe di calore e mobilità elettrica.', + lead: 'Lavoriamo con produttori selezionati per affidabilità, assistenza e disponibilità dei ricambi.', + sections: [ + { + heading: 'Come scegliamo i partner', + paragraphs: ['Selezioniamo prodotti con garanzie solide, assistenza in Italia e ricambi disponibili nel tempo.'], + items: ['Qualità e durata dei componenti', 'Assistenza tecnica del produttore', 'Garanzie chiare'], + }, + { + heading: 'I nostri marchi', + paragraphs: ['[Segnaposto] Elenco dei marchi partner con logo, da inserire dopo conferma dell’azienda.'], + }, + { + heading: 'Installatori', + paragraphs: ['Sei un installatore? Registrati all’area riservata per accedere al listino dedicato e alla documentazione tecnica.'], + }, + ], + cta: { label: 'Diventa installatore partner', path: '/contatti', query: { oggetto: 'installatori' } }, +}) diff --git a/apps/public/src/content/content.spec.ts b/apps/public/src/content/content.spec.ts new file mode 100644 index 0000000..bcf1467 --- /dev/null +++ b/apps/public/src/content/content.spec.ts @@ -0,0 +1,89 @@ +import { faqJsonLd } from '../app/core/structured-data' +import { staticSitemapPaths } from '../app/core/site' +import { AREAS } from './areas' +import { SOLUTIONS, solutionsOf } from './catalog' +import { COMPANY_PAGES } from './company-pages' +import { AREA_SLUGS, COMPANY_PAGE_SLUGS, SOLUTION_PATHS } from './generated/paths' +import { NEEDS } from './needs' +import { PROJECTS } from './projects' +import type { ConfiguratorPath } from './types' + +/** Coerenza dei file di contenuto: fallisce se un file nuovo o modificato è incompleto o incoerente. */ + +const CONFIGURATOR_PATHS: ConfiguratorPath[] = ['acqua', 'fotovoltaico', 'pompa_calore', 'azienda'] +const HANDLE = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +describe('file di contenuto', () => { + it('slug = nome del file (aree, soluzioni con cartella = area, pagine azienda)', () => { + expect([...AREAS.map((a) => a.slug)].sort()).toEqual([...AREA_SLUGS]) + expect(SOLUTIONS.map((s) => `${s.area}/${s.slug}`).sort()).toEqual(SOLUTION_PATHS.map((p) => `${p.area}/${p.slug}`).sort()) + expect([...COMPANY_PAGES.map((p) => p.slug)].sort()).toEqual([...COMPANY_PAGE_SLUGS]) + expect(COMPANY_PAGES[0]?.slug).toBe('chi-siamo') + }) + + it('ogni macro-area del menu ha almeno una soluzione', () => { + for (const area of AREAS) expect(solutionsOf(area.slug).length, area.slug).toBeGreaterThan(0) + }) + + it('campi obbligatori, meta description di lunghezza ragionevole, ordini univoci', () => { + for (const c of [...AREAS, ...SOLUTIONS]) { + const id = 'area' in c ? `${c.area}/${c.slug}` : c.slug + expect(c.title.trim(), id).not.toBe('') + expect(c.description.length, `${id}: description`).toBeGreaterThanOrEqual(70) + expect(c.description.length, `${id}: description`).toBeLessThanOrEqual(170) + expect(c.intro.length, id).toBeGreaterThan(0) + expect(c.image.src.startsWith('/'), `${id}: image.src`).toBe(true) + expect(c.image.alt.trim(), `${id}: image.alt`).not.toBe('') + for (const h of c.relatedProducts) expect(h, `${id}: handle`).toMatch(HANDLE) + if (c.configuratorPath) expect(CONFIGURATOR_PATHS).toContain(c.configuratorPath) + } + expect(new Set(AREAS.map((a) => a.order)).size).toBe(AREAS.length) + for (const area of AREAS) { + const orders = solutionsOf(area.slug).map((s) => s.order) + expect(new Set(orders).size, area.slug).toBe(orders.length) + } + for (const page of COMPANY_PAGES) { + expect(page.description.length, page.slug).toBeLessThanOrEqual(170) + expect(page.sections.length, page.slug).toBeGreaterThan(0) + } + }) + + it('FAQ → JSON-LD FAQPage valido', () => { + for (const c of [...AREAS, ...SOLUTIONS]) { + if (!c.faq.length) continue + const ld = JSON.parse(JSON.stringify(faqJsonLd(c.faq))) as { + '@context': string + '@type': string + mainEntity: { '@type': string; name: string; acceptedAnswer: { '@type': string; text: string } }[] + } + expect(ld['@context']).toBe('https://schema.org') + expect(ld['@type']).toBe('FAQPage') + expect(ld.mainEntity).toHaveLength(c.faq.length) + for (const q of ld.mainEntity) { + expect(q['@type']).toBe('Question') + expect(q.name.trim()).not.toBe('') + expect(q.acceptedAnswer['@type']).toBe('Answer') + expect(q.acceptedAnswer.text.trim()).not.toBe('') + expect(q.acceptedAnswer.text).not.toMatch(/<[a-z]/i) // solo testo semplice + } + } + }) + + it('esigenze e realizzazioni puntano ad aree e percorsi esistenti', () => { + const areas = new Set(AREAS.map((a) => a.slug)) + for (const need of NEEDS) { + expect(CONFIGURATOR_PATHS).toContain(need.path) + expect(areas.has(need.area), need.id).toBe(true) + } + for (const p of PROJECTS) expect(areas.has(p.area), p.id).toBe(true) + expect(new Set(PROJECTS.map((p) => p.id)).size).toBe(PROJECTS.length) + expect(PROJECTS.filter((p) => p.featured).length).toBeGreaterThanOrEqual(3) + }) + + it('sitemap: tutte le aree e soluzioni, pagine azienda senza il duplicato chi-siamo', () => { + const paths = staticSitemapPaths() + for (const s of SOLUTIONS) expect(paths).toContain(`/soluzioni/${s.area}/${s.slug}`) + expect(paths).toContain('/azienda/lavora-con-noi') + expect(paths).not.toContain('/azienda/chi-siamo') + }) +}) diff --git a/apps/public/src/content/home.ts b/apps/public/src/content/home.ts new file mode 100644 index 0000000..2da979a --- /dev/null +++ b/apps/public/src/content/home.ts @@ -0,0 +1,67 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda. +// Recensioni e marchi sono ESEMPI: non pubblicare recensioni o loghi non reali/non autorizzati. + +/** Testi della home (src/app/pages/home.ts), nell'ordine delle sezioni. */ +export const HOME = { + placeholder: true, + seo: { + title: 'Trattamento acqua, fotovoltaico, pompe di calore', + description: + 'ITI TECHNOLOGY: trattamento acqua, impianti fotovoltaici e accumulo, pompe di calore e climatizzazione, ricarica per auto elettriche e soluzioni industriali.', + }, + hero: { + title: 'Acqua pulita, energia tua, comfort in casa', + payoff: 'Progettiamo, installiamo e seguiamo nel tempo le soluzioni per acqua, energia e clima di famiglie e aziende.', + }, + sectors: { + title: 'Le nostre soluzioni', + lead: 'Cinque aree, un unico referente. Parti dall’acqua: è da lì che siamo nati.', + }, + energy: { + title: 'Energia rinnovabile, senza pensieri', + text: 'Fotovoltaico e accumulo dimensionati sui tuoi consumi, con pratiche, installazione e monitoraggio inclusi.', + points: ['Stima della produzione e del risparmio', 'Batterie anche su impianti esistenti', 'Pompa di calore e wallbox alimentate dal sole'], + }, + privateServices: { + title: 'Per la casa', + items: [ + { title: 'Sopralluogo gratuito', text: "Analisi dell'acqua, del tetto o dell'impianto di riscaldamento." }, + { title: 'Preventivo chiaro', text: 'Costi, tempi e stima dei risparmi, senza sorprese.' }, + { title: 'Installazione', text: 'Tecnici qualificati e lavori puliti, nei tempi concordati.' }, + { title: 'Assistenza', text: 'Manutenzione programmata e interventi rapidi.' }, + ], + }, + businessServices: { + title: 'Per le aziende', + items: [ + { title: 'Diagnosi energetica', text: 'Consumi, contratti e opportunità di risparmio.' }, + { title: 'Acque di processo', text: 'Trattamenti per caldaie, lavaggi e linee produttive.' }, + { title: 'Fotovoltaico industriale', text: 'Impianti su capannoni e pensiline.' }, + { title: 'Contratti di manutenzione', text: 'Interventi pianificati e reperibilità.' }, + ], + }, + configurator: { + title: 'Qual è la tua esigenza?', + lead: 'Rispondi a poche domande e ricevi subito una soluzione indicativa, con stima di costi e risparmi.', + }, + projects: { title: 'Realizzazioni', lead: 'Alcuni dei lavori che abbiamo completato.' }, + /** Marchi partner: ESEMPI. Inserire solo marchi reali con autorizzazione all'uso del nome/logo. */ + partners: { + title: 'Partner e marchi', + items: ['Marchio partner 1', 'Marchio partner 2', 'Marchio partner 3', 'Marchio partner 4', 'Marchio partner 5', 'Marchio partner 6'], + }, + /** Recensioni: ESEMPI. Pubblicare solo recensioni reali, con il consenso dell'autore. Niente JSON-LD Review finché non sono reali. */ + reviews: { + title: 'Dicono di noi', + items: [ + { author: 'Cliente di esempio', place: '[Località]', text: 'Testo di esempio di una recensione sul fotovoltaico: sostituire con una recensione reale.' }, + { author: 'Cliente di esempio', place: '[Località]', text: "Testo di esempio di una recensione sull'addolcitore: sostituire con una recensione reale." }, + { author: 'Cliente di esempio', place: '[Località]', text: 'Testo di esempio di una recensione sulla pompa di calore: sostituire con una recensione reale.' }, + ], + }, + blog: { title: 'Dal blog', lead: 'Guide, incentivi e novità.' }, + contact: { + title: 'Parliamone', + text: 'Scrivici per un sopralluogo o un preventivo, oppure prenota una visita allo showroom.', + }, +} as const diff --git a/apps/public/src/content/legal.ts b/apps/public/src/content/legal.ts new file mode 100644 index 0000000..bae75e5 --- /dev/null +++ b/apps/public/src/content/legal.ts @@ -0,0 +1,128 @@ +// ⚠️ TESTI SEGNAPOSTO — DA VALIDARE CON CONSULENTE PRIVACY prima della pubblicazione (poi validated: true). +import type { ContentSection } from './types' + +/** Riga della tabella dei cookie e degli strumenti di memorizzazione locale. */ +export interface CookieRow { + name: string + provider: string + purpose: string + duration: string + category: 'Necessario' | 'Statistiche' | 'Marketing' +} + +export interface LegalPageContent { + /** false finché un consulente privacy non ha validato il testo: la pagina mostra un avviso ben visibile. */ + validated: boolean + title: string + description: string + lead: string + /** Data dell'ultimo aggiornamento (YYYY-MM-DD). */ + updatedAt: string + sections: ContentSection[] +} + +export const PRIVACY_POLICY: LegalPageContent = { + validated: false, + title: 'Privacy policy', + description: 'Informativa sul trattamento dei dati personali di ITI TECHNOLOGY ai sensi del Regolamento UE 2016/679 (GDPR).', + lead: 'Come trattiamo i dati personali di chi visita il sito, ci contatta o acquista i nostri prodotti.', + updatedAt: '2026-10-03', + sections: [ + { + heading: 'Titolare del trattamento', + paragraphs: [ + '[Segnaposto] Ragione sociale, sede legale, P.IVA e contatti del titolare (da company.ts). Eventuale responsabile della protezione dei dati (DPO) e suoi contatti.', + ], + }, + { + heading: 'Dati trattati e finalità', + paragraphs: ['[Segnaposto] Trattiamo i dati che ci fornisci e quelli necessari al funzionamento del sito, per queste finalità:'], + items: [ + 'Risposta alle richieste inviate dai form contatti, configuratore e prenotazione showroom (base giuridica: misure precontrattuali richieste dall’interessato).', + 'Gestione degli ordini dello shop, dei pagamenti, delle spedizioni e della fatturazione (esecuzione del contratto, obblighi di legge).', + 'Gestione dell’account nell’area riservata, dei preventivi e delle richieste di assistenza (esecuzione del contratto).', + 'Invio di novità e offerte via email, solo con consenso esplicito e revocabile (consenso).', + 'Sicurezza del sito e prevenzione degli abusi, ad esempio limiti al numero di richieste (legittimo interesse).', + ], + }, + { + heading: 'Destinatari', + paragraphs: [ + '[Segnaposto] Fornitori che trattano i dati per nostro conto come responsabili del trattamento: hosting, invio delle email transazionali (provider da definire), pagamenti (Stripe), corrieri, consulenti fiscali. Installatori partner, solo per gli interventi richiesti.', + ], + }, + { + heading: 'Trasferimenti fuori dall’Unione europea', + paragraphs: [ + '[Segnaposto] Indicare i fornitori con sede o server fuori dall’UE (es. Stripe) e le garanzie adottate (decisione di adeguatezza, clausole contrattuali standard).', + ], + }, + { + heading: 'Conservazione', + paragraphs: [ + '[Segnaposto] Per quanto tempo conserviamo ciascuna categoria di dati: richieste di contatto, ordini e fatture (10 anni per gli obblighi fiscali), account, consensi (come prova, anche dopo la revoca).', + ], + }, + { + heading: 'I tuoi diritti', + paragraphs: [ + 'Puoi chiedere accesso, rettifica, cancellazione, limitazione e portabilità dei dati, opporti al trattamento e revocare in ogni momento il consenso al marketing (anche dall’area riservata). Puoi proporre reclamo al Garante per la protezione dei dati personali.', + '[Segnaposto] Indirizzo a cui inviare le richieste.', + ], + }, + { + heading: 'Cookie', + paragraphs: ['Per i cookie e le tecnologie simili usate dal sito consulta la cookie policy.'], + }, + ], +} + +export const COOKIE_POLICY: LegalPageContent = { + validated: false, + title: 'Cookie policy', + description: 'Informativa sui cookie e sulle tecnologie di tracciamento usate dal sito ITI TECHNOLOGY e su come gestire le tue preferenze.', + lead: 'Quali cookie usiamo, a cosa servono e come gestire le tue preferenze.', + updatedAt: '2026-10-03', + sections: [ + { + heading: 'Cosa sono i cookie', + paragraphs: [ + 'I cookie sono piccoli file che il sito salva nel browser. Quelli tecnici sono necessari al funzionamento del sito e non richiedono consenso. Gli altri (statistici, di marketing) si usano solo se li accetti.', + ], + }, + { + heading: 'Cookie che usiamo oggi', + paragraphs: [ + 'Oggi il sito usa solo cookie tecnici e memorizzazione locale del browser, elencati nella tabella qui sotto. Non usiamo cookie statistici, di profilazione o di terze parti a fini pubblicitari. Se in futuro ne aggiungeremo, verranno attivati solo con il tuo consenso e aggiorneremo questa pagina.', + ], + }, + { + heading: 'Servizi di terze parti', + paragraphs: [ + 'Pagamenti: al passo di pagamento del checkout viene caricato Stripe, che può impostare propri cookie tecnici per la prevenzione delle frodi (es. __stripe_mid, __stripe_sid).', + 'Mappa: la mappa della pagina Contatti è fornita da OpenStreetMap e viene caricata solo se premi "Mostra mappa". Non imposta cookie, ma il tuo browser si collega ai server di OpenStreetMap (che ricevono il tuo indirizzo IP).', + ], + }, + { + heading: 'Come gestire le preferenze', + paragraphs: [ + 'Puoi cambiare le tue scelte in qualsiasi momento con il pulsante qui sotto o dal link "Preferenze cookie" in fondo a ogni pagina. La scelta viene ricordata per 6 mesi, poi te la richiediamo. Puoi anche cancellare i cookie dalle impostazioni del browser.', + ], + }, + ], +} + +export const COOKIE_TABLE: CookieRow[] = [ + { name: 'iti_consent', provider: 'Questo sito', purpose: 'Ricorda le tue scelte sui cookie', duration: '6 mesi', category: 'Necessario' }, + { name: 'iti_rt', provider: 'Questo sito', purpose: 'Mantiene l’accesso all’area riservata (solo dopo il login)', duration: '30 giorni', category: 'Necessario' }, + { name: 'iti_session', provider: 'Questo sito', purpose: 'Indica che è attiva una sessione dell’area riservata', duration: '30 giorni', category: 'Necessario' }, + { name: 'cart_id', provider: 'Questo sito', purpose: 'Ricorda il carrello dello shop (solo dopo aver aggiunto un prodotto)', duration: '30 giorni', category: 'Necessario' }, + { + name: 'iti-configurator, iti-configurator-done, iti-last-order (sessionStorage)', + provider: 'Questo sito', + purpose: 'Risposte del configuratore e riepiloghi delle richieste e degli ordini nella scheda aperta', + duration: 'Fino alla chiusura della scheda', + category: 'Necessario', + }, + { name: '__stripe_mid, __stripe_sid', provider: 'Stripe (solo al pagamento)', purpose: 'Prevenzione delle frodi nei pagamenti', duration: '1 anno / 30 minuti', category: 'Necessario' }, +] diff --git a/apps/public/src/content/needs.ts b/apps/public/src/content/needs.ts new file mode 100644 index 0000000..e59ee0a --- /dev/null +++ b/apps/public/src/content/needs.ts @@ -0,0 +1,22 @@ +// ⚠️ TESTO SEGNAPOSTO: da rivedere con l'azienda. +import type { ConfiguratorPath } from './types' + +/** + * Esigenze del componente "Qual è la tua esigenza?": ognuna porta al configuratore con il percorso + * preselezionato (/configuratore?percorso=, letto dal wizard del prompt 27). + */ +export interface Need { + id: string + title: string + text: string + path: ConfiguratorPath + /** Slug della macro-area (colore e icona). */ + area: string +} + +export const NEEDS: Need[] = [ + { id: 'calcare', title: "Ho troppo calcare nell'acqua", text: 'Addolcitore o filtrazione su misura per la tua casa.', path: 'acqua', area: 'acqua' }, + { id: 'bolletta-luce', title: 'Voglio ridurre la bolletta della luce', text: 'Fotovoltaico, con o senza batteria.', path: 'fotovoltaico', area: 'energia' }, + { id: 'riscaldamento', title: 'Voglio cambiare il riscaldamento', text: 'Pompa di calore al posto di caldaia, gasolio o GPL.', path: 'pompa_calore', area: 'comfort' }, + { id: 'azienda', title: 'Ho un’azienda', text: 'Acqua di processo, fotovoltaico industriale, ricarica.', path: 'azienda', area: 'industria' }, +] diff --git a/apps/public/src/content/projects.ts b/apps/public/src/content/projects.ts new file mode 100644 index 0000000..a9a93f3 --- /dev/null +++ b/apps/public/src/content/projects.ts @@ -0,0 +1,71 @@ +// ⚠️ CONTENUTI SEGNAPOSTO: realizzazioni di esempio, da sostituire con lavori reali (con foto e consenso del cliente). +import type { ProjectContent } from './types' + +const img = (area: string, alt: string) => ({ src: `/images/aree/${area}.svg`, alt, width: 1200, height: 800 }) + +/** Galleria /realizzazioni. `featured: true` → mostrata anche in home (le prime tre). */ +export const PROJECTS: ProjectContent[] = [ + { + placeholder: true, + id: 'fv-villetta-2026', + title: 'Fotovoltaico 6 kW con accumulo', + area: 'energia', + location: '[Località]', + year: 2026, + summary: 'Impianto su tetto a falda per una famiglia di quattro persone, con batteria da 10 kWh.', + image: img('energia', 'Impianto fotovoltaico su tetto a falda'), + featured: true, + }, + { + placeholder: true, + id: 'addolcitore-condominio-2026', + title: 'Addolcitore centralizzato per condominio', + area: 'acqua', + location: '[Località]', + year: 2026, + summary: 'Impianto di addolcimento a doppia colonna per 12 unità abitative.', + image: img('acqua', 'Addolcitore centralizzato in un locale tecnico'), + featured: true, + }, + { + placeholder: true, + id: 'pdc-casa-2025', + title: 'Pompa di calore al posto del gasolio', + area: 'comfort', + location: '[Località]', + year: 2025, + summary: 'Sostituzione della caldaia a gasolio con pompa di calore da 12 kW e accumulo per l’acqua calda.', + image: img('comfort', 'Unità esterna di una pompa di calore accanto a una casa'), + featured: true, + }, + { + placeholder: true, + id: 'wallbox-azienda-2025', + title: 'Ricarica per la flotta aziendale', + area: 'mobilita', + location: '[Località]', + year: 2025, + summary: 'Sei punti di ricarica con bilanciamento dinamico della potenza.', + image: img('mobilita', 'Stazioni di ricarica in un parcheggio aziendale'), + }, + { + placeholder: true, + id: 'fv-capannone-2025', + title: 'Fotovoltaico industriale 200 kW', + area: 'industria', + location: '[Località]', + year: 2025, + summary: 'Impianto su copertura di capannone con monitoraggio e manutenzione programmata.', + image: img('industria', 'Pannelli fotovoltaici sulla copertura di un capannone'), + }, + { + placeholder: true, + id: 'osmosi-ristorante-2024', + title: 'Osmosi inversa per un ristorante', + area: 'acqua', + location: '[Località]', + year: 2024, + summary: 'Acqua microfiltrata per la sala e trattamento per lavastoviglie e macchina del caffè.', + image: img('acqua', 'Impianto di osmosi inversa in una cucina professionale'), + }, +] diff --git a/apps/public/src/content/showroom.ts b/apps/public/src/content/showroom.ts new file mode 100644 index 0000000..c880d6b --- /dev/null +++ b/apps/public/src/content/showroom.ts @@ -0,0 +1,26 @@ +// ⚠️ TESTO E FOTO SEGNAPOSTO: da sostituire con i contenuti forniti dall'azienda (poi placeholder: false). +import type { ContentImage } from './types' + +/** Testi e foto della pagina /showroom. Orari in company.ts (allineati a apps/api/src/showroom/config.ts). */ +export const SHOWROOM_PAGE: { + placeholder: boolean + intro: string[] + highlights: { title: string; text: string }[] + photos: ContentImage[] +} = { + placeholder: true, + intro: [ + 'Nel nostro showroom puoi vedere da vicino addolcitori, sistemi di filtrazione, pompe di calore, inverter e batterie, e parlare con un tecnico della tua casa o della tua azienda.', + 'La visita dura circa un’ora ed è gratuita. Prenota un orario: così ti dedichiamo tutto il tempo che serve.', + ], + highlights: [ + { title: 'Prodotti funzionanti', text: 'Impianti dimostrativi accesi: vedi come lavorano e quanto spazio occupano.' }, + { title: 'Un tecnico dedicato', text: 'Rispondiamo alle tue domande e facciamo una prima valutazione sulle tue esigenze.' }, + { title: 'Porta le bollette', text: 'Con consumi e spese alla mano possiamo darti una stima più precisa.' }, + ], + photos: [ + { src: '/images/showroom/sala.svg', alt: 'Sala espositiva dello showroom (immagine segnaposto)', width: 1200, height: 800 }, + { src: '/images/showroom/acqua.svg', alt: 'Area dedicata al trattamento dell’acqua (immagine segnaposto)', width: 1200, height: 800 }, + { src: '/images/showroom/energia.svg', alt: 'Area dedicata a fotovoltaico e pompe di calore (immagine segnaposto)', width: 1200, height: 800 }, + ], +} diff --git a/apps/public/src/content/solutions/acqua/addolcitori.ts b/apps/public/src/content/solutions/acqua/addolcitori.ts new file mode 100644 index 0000000..0e92d83 --- /dev/null +++ b/apps/public/src/content/solutions/acqua/addolcitori.ts @@ -0,0 +1,40 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'addolcitori', + area: 'acqua', + order: 1, + title: 'Addolcitori', + tagline: 'Eliminano il calcare e proteggono impianti ed elettrodomestici.', + description: + 'Addolcitori per la casa e per piccole attività: niente più calcare su rubinetti, caldaia ed elettrodomestici. Analisi gratuita della durezza.', + intro: [ + "L'addolcitore trattiene calcio e magnesio, responsabili del calcare, grazie a resine che si rigenerano automaticamente con il sale.", + "Lo dimensioniamo sul numero di persone e sulla durezza dell'acqua, e lo regoliamo per lasciare la durezza residua prevista dalla normativa.", + ], + image: { src: '/images/aree/acqua.svg', alt: 'Illustrazione di un addolcitore domestico', width: 1200, height: 800 }, + benefits: [ + { title: 'Elettrodomestici più longevi', text: 'Niente incrostazioni su resistenze e scambiatori.' }, + { title: 'Meno detersivi', text: "Con l'acqua addolcita serve meno detersivo e il bucato resta morbido." }, + { title: 'Rigenerazione automatica', text: 'Il ciclo parte in base ai consumi reali, anche di notte.' }, + ], + steps: [ + { title: 'Misura della durezza', text: 'Analisi sul posto in gradi francesi (°f).' }, + { title: 'Scelta della taglia', text: 'Volume di resina in base a persone e durezza.' }, + { title: 'Installazione e taratura', text: 'Bypass, miscelazione e programmazione della rigenerazione.' }, + ], + relatedProducts: ['addolcitore-domestico-20l'], + faq: [ + { + question: 'Quanto sale consuma un addolcitore?', + answer: 'Per una famiglia di 3-4 persone indicativamente 4-8 kg al mese, in funzione della durezza dell’acqua e dei consumi.', + }, + { + question: 'Serve uno spazio particolare?', + answer: 'Basta un locale vicino all’ingresso dell’acqua, con uno scarico e una presa elettrica: garage, cantina o locale caldaia.', + }, + ], + configuratorPath: 'acqua', +}) diff --git a/apps/public/src/content/solutions/acqua/filtrazione.ts b/apps/public/src/content/solutions/acqua/filtrazione.ts new file mode 100644 index 0000000..4fbe95d --- /dev/null +++ b/apps/public/src/content/solutions/acqua/filtrazione.ts @@ -0,0 +1,36 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'filtrazione', + area: 'acqua', + order: 2, + title: 'Filtrazione', + tagline: "Filtri per sedimenti, cloro e impurità dell'acqua di rete o di pozzo.", + description: + "Filtri per l'acqua di casa: sabbia, ruggine, sedimenti e cloro trattenuti all'ingresso dell'impianto. Soluzioni anche per acqua di pozzo.", + intro: [ + "Sabbia, ruggine e piccoli sedimenti arrivano con l'acqua di rete o di pozzo e danneggiano valvole, rubinetti ed elettrodomestici.", + "Un filtro all'ingresso dell'impianto li trattiene; a seconda dell'acqua aggiungiamo carboni attivi per cloro, odori e sapori.", + ], + image: { src: '/images/aree/acqua.svg', alt: "Illustrazione di un filtro per l'acqua", width: 1200, height: 800 }, + benefits: [ + { title: 'Impianto protetto', text: 'Valvole, miscelatori e boiler al riparo dai sedimenti.' }, + { title: 'Acqua più gradevole', text: 'Meno odore e sapore di cloro con i filtri a carbone attivo.' }, + { title: 'Manutenzione semplice', text: 'Cartucce sostituibili o filtri autopulenti.' }, + ], + steps: [ + { title: 'Analisi', text: "Valutiamo provenienza e caratteristiche dell'acqua." }, + { title: 'Scelta del filtro', text: 'Grado di filtrazione e portata adatti all’impianto.' }, + { title: 'Installazione', text: 'Montaggio con bypass per la manutenzione.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Ogni quanto si cambia la cartuccia?', + answer: "In genere ogni 6-12 mesi, prima se l'acqua è molto carica di sedimenti. I filtri autopulenti richiedono solo un lavaggio periodico.", + }, + ], + configuratorPath: 'acqua', +}) diff --git a/apps/public/src/content/solutions/acqua/osmosi-inversa.ts b/apps/public/src/content/solutions/acqua/osmosi-inversa.ts new file mode 100644 index 0000000..d4d4b0d --- /dev/null +++ b/apps/public/src/content/solutions/acqua/osmosi-inversa.ts @@ -0,0 +1,40 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'osmosi-inversa', + area: 'acqua', + order: 3, + title: 'Osmosi inversa', + tagline: 'Acqua da bere purificata direttamente dal rubinetto.', + description: + 'Depuratori a osmosi inversa sotto il lavello: acqua da bere purificata dal rubinetto, senza bottiglie di plastica. Installazione e manutenzione.', + intro: [ + "L'osmosi inversa fa passare l'acqua attraverso una membrana finissima che trattiene la maggior parte dei sali e delle sostanze disciolte.", + "Il depuratore si installa sotto il lavello con un rubinetto dedicato: acqua buona da bere e per cucinare, senza comprare né trasportare bottiglie.", + ], + image: { src: '/images/aree/acqua.svg', alt: 'Illustrazione di un depuratore a osmosi inversa', width: 1200, height: 800 }, + benefits: [ + { title: 'Niente bottiglie', text: 'Meno plastica, meno spesa e niente casse d’acqua da portare.' }, + { title: 'Gusto migliore', text: 'Ideale per bere, cucinare, caffè e tè.' }, + { title: 'Compatto', text: 'Sta sotto il lavello, con rubinetto dedicato o miscelatore a tre vie.' }, + ], + steps: [ + { title: 'Sopralluogo', text: 'Verifichiamo spazio, pressione e qualità dell’acqua.' }, + { title: 'Installazione', text: 'Montaggio sotto il lavello e rubinetto dedicato.' }, + { title: 'Manutenzione', text: 'Sostituzione periodica di filtri e sanificazione.' }, + ], + relatedProducts: [], + faq: [ + { + question: "L'acqua dell'osmosi è troppo povera di sali?", + answer: 'I depuratori domestici hanno un regolatore di salinità che permette di mantenere un contenuto di sali equilibrato.', + }, + { + question: 'Quanto costa la manutenzione?', + answer: 'Prevede la sostituzione dei prefiltri circa una volta all’anno e della membrana ogni qualche anno: il costo è indicato nel preventivo.', + }, + ], + configuratorPath: 'acqua', +}) diff --git a/apps/public/src/content/solutions/comfort/climatizzazione.ts b/apps/public/src/content/solutions/comfort/climatizzazione.ts new file mode 100644 index 0000000..ae2faa5 --- /dev/null +++ b/apps/public/src/content/solutions/comfort/climatizzazione.ts @@ -0,0 +1,35 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'climatizzazione', + area: 'comfort', + order: 2, + title: 'Climatizzazione', + tagline: 'Climatizzatori mono e multi-split per ogni ambiente.', + description: + 'Climatizzatori mono e multi-split in classe energetica elevata: fresco d’estate, caldo nelle mezze stagioni. Installazione e manutenzione.', + intro: [ + 'I climatizzatori inverter raffrescano d’estate e riscaldano con efficienza nelle mezze stagioni.', + 'Progettiamo la posizione delle unità e il percorso delle linee per un risultato pulito e silenzioso.', + ], + image: { src: '/images/aree/comfort.svg', alt: 'Illustrazione di un climatizzatore a parete', width: 1200, height: 800 }, + benefits: [ + { title: 'Inverter', text: 'Regola la potenza ed evita sprechi e sbalzi di temperatura.' }, + { title: 'Multi-split', text: 'Una sola unità esterna per più stanze.' }, + { title: 'Aria più pulita', text: 'Filtri per polveri e allergeni, con manutenzione periodica.' }, + ], + steps: [ + { title: 'Sopralluogo', text: 'Ambienti da climatizzare e posizione delle unità.' }, + { title: 'Installazione', text: 'Linee frigorifere, scarico condensa e collegamenti.' }, + { title: 'Manutenzione', text: 'Pulizia filtri e controllo del gas refrigerante.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Serve un permesso per l’unità esterna?', + answer: 'In genere no, ma in condominio o in zone vincolate possono esserci regole sulla posizione: le verifichiamo prima dell’installazione.', + }, + ], +}) diff --git a/apps/public/src/content/solutions/comfort/pompe-di-calore.ts b/apps/public/src/content/solutions/comfort/pompe-di-calore.ts new file mode 100644 index 0000000..fdc603c --- /dev/null +++ b/apps/public/src/content/solutions/comfort/pompe-di-calore.ts @@ -0,0 +1,41 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'pompe-di-calore', + area: 'comfort', + order: 1, + title: 'Pompe di calore', + tagline: 'Riscaldamento e acqua calda sanitaria ad alta efficienza.', + description: + 'Pompe di calore aria-acqua per riscaldamento, raffrescamento e acqua calda: dimensionamento sulla casa reale, installazione e assistenza.', + intro: [ + 'La pompa di calore aria-acqua sostituisce la caldaia e alimenta radiatori, pavimento radiante o ventilconvettori.', + "Valutiamo l'impianto esistente e dimensioniamo la potenza sulla dispersione reale dell'edificio, per un comfort costante e consumi contenuti.", + ], + image: { src: '/images/aree/comfort.svg', alt: 'Illustrazione di una pompa di calore aria-acqua', width: 1200, height: 800 }, + benefits: [ + { title: 'Alta efficienza', text: 'Fino a 3-4 kWh di calore per ogni kWh elettrico.' }, + { title: 'Tutto in uno', text: 'Riscaldamento, raffrescamento e acqua calda sanitaria.' }, + { title: 'Abbinabile al fotovoltaico', text: "Scaldi casa con l'energia del tuo tetto." }, + ], + steps: [ + { title: 'Sopralluogo', text: 'Superficie, isolamento, terminali e impianto esistente.' }, + { title: 'Calcolo della potenza', text: 'Taglia commerciale adatta alla tua casa.' }, + { title: 'Installazione', text: 'Unità esterna, modulo interno e collegamenti.' }, + { title: 'Messa in servizio', text: 'Taratura della curva climatica e istruzioni d’uso.' }, + ], + relatedProducts: ['pompa-di-calore-aria-acqua-8kw'], + faq: [ + { + question: "Quanto spazio serve per l'unità esterna?", + answer: "L'unità esterna ha ingombri simili a quella di un climatizzatore di grande potenza: va posizionata in un punto ventilato, rispettando le distanze dai confini.", + }, + { + question: 'Fa rumore?', + answer: 'I modelli attuali sono silenziosi; in fase di progetto scegliamo la posizione e i supporti antivibranti per limitare il rumore percepito.', + }, + ], + configuratorPath: 'pompa_calore', +}) diff --git a/apps/public/src/content/solutions/energia/accumulo.ts b/apps/public/src/content/solutions/energia/accumulo.ts new file mode 100644 index 0000000..c9e648b --- /dev/null +++ b/apps/public/src/content/solutions/energia/accumulo.ts @@ -0,0 +1,36 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'accumulo', + area: 'energia', + order: 2, + title: 'Sistemi di accumulo', + tagline: "Batterie per usare di sera l'energia prodotta di giorno.", + description: + "Batterie di accumulo per impianti fotovoltaici nuovi ed esistenti: più autoconsumo, meno energia prelevata dalla rete la sera.", + intro: [ + "Senza batteria l'energia che non consumi subito va in rete; con l'accumulo la conservi e la usi la sera e di notte.", + "Installiamo batterie su impianti nuovi o già esistenti, verificando la compatibilità con l'inverter.", + ], + image: { src: '/images/aree/energia.svg', alt: 'Illustrazione di una batteria di accumulo domestica', width: 1200, height: 800 }, + benefits: [ + { title: 'Più autoconsumo', text: "Usi una quota maggiore dell'energia che produci." }, + { title: 'Anche su impianti esistenti', text: 'Retrofit con inverter ibrido o batteria lato AC.' }, + { title: 'Modulare', text: 'Capacità ampliabile in futuro.' }, + ], + steps: [ + { title: 'Analisi dei consumi serali', text: 'Quanta energia ti serve dopo il tramonto.' }, + { title: 'Scelta della capacità', text: 'kWh di accumulo coerenti con impianto e consumi.' }, + { title: 'Installazione', text: 'Collegamento, configurazione e collaudo.' }, + ], + relatedProducts: ['kit-fotovoltaico-6kw-accumulo'], + faq: [ + { + question: 'Quanto dura una batteria?', + answer: 'Le batterie al litio domestiche sono garantite in genere per 10 anni o per un numero di cicli definito dal produttore.', + }, + ], + configuratorPath: 'fotovoltaico', +}) diff --git a/apps/public/src/content/solutions/energia/fotovoltaico.ts b/apps/public/src/content/solutions/energia/fotovoltaico.ts new file mode 100644 index 0000000..44e3229 --- /dev/null +++ b/apps/public/src/content/solutions/energia/fotovoltaico.ts @@ -0,0 +1,41 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'fotovoltaico', + area: 'energia', + order: 1, + title: 'Impianti fotovoltaici', + tagline: 'Impianti residenziali chiavi in mano, dal sopralluogo alla connessione.', + description: + 'Impianti fotovoltaici residenziali chiavi in mano: sopralluogo, progetto, pratiche, installazione e monitoraggio. Stima gratuita con il configuratore.', + intro: [ + "Progettiamo l'impianto sui tuoi consumi reali e sul tuo tetto, per massimizzare l'energia che usi direttamente in casa.", + 'Moduli e inverter di marchi affidabili, installazione con personale qualificato, pratiche di connessione e monitoraggio inclusi.', + ], + image: { src: '/images/aree/energia.svg', alt: 'Illustrazione di un impianto fotovoltaico su un tetto', width: 1200, height: 800 }, + benefits: [ + { title: 'Dimensionato sui consumi', text: 'Né troppo piccolo né sovradimensionato: lo calcoliamo sulle tue bollette.' }, + { title: 'Pratiche incluse', text: 'Ci occupiamo noi di comunicazioni e connessione con il gestore.' }, + { title: 'Monitoraggio', text: 'Controlli produzione e consumi dallo smartphone.' }, + ], + steps: [ + { title: 'Analisi delle bollette', text: 'Consumi annui e fasce orarie.' }, + { title: 'Sopralluogo e progetto', text: 'Orientamento, inclinazione, ombre e spazio disponibile.' }, + { title: 'Installazione', text: 'Montaggio, collegamenti e collaudo.' }, + { title: 'Connessione', text: 'Attivazione dello scambio con la rete.' }, + ], + relatedProducts: ['kit-fotovoltaico-6kw-accumulo'], + faq: [ + { + question: 'Quanto produce un impianto da 1 kW?', + answer: "In Italia indicativamente tra 1.100 e 1.500 kWh all'anno, a seconda della zona, dell'orientamento e dell'inclinazione.", + }, + { + question: 'Quanto dura un impianto?', + answer: 'I moduli hanno una vita utile di 25-30 anni con un calo di rendimento graduale; l’inverter può richiedere una sostituzione nel corso della vita dell’impianto.', + }, + ], + configuratorPath: 'fotovoltaico', +}) diff --git a/apps/public/src/content/solutions/industria/acque-di-processo.ts b/apps/public/src/content/solutions/industria/acque-di-processo.ts new file mode 100644 index 0000000..6863747 --- /dev/null +++ b/apps/public/src/content/solutions/industria/acque-di-processo.ts @@ -0,0 +1,36 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'acque-di-processo', + area: 'industria', + order: 1, + title: 'Trattamento acque di processo', + tagline: 'Impianti per acque tecniche, di processo e di scarico.', + description: + 'Impianti di trattamento per acque tecniche e di processo: addolcimento, filtrazione e osmosi industriale per caldaie, lavaggi e linee produttive.', + intro: [ + "Ogni processo richiede un'acqua con caratteristiche precise: durezza, conducibilità, assenza di solidi sospesi.", + "Analizziamo l'acqua in ingresso e i requisiti del processo, poi progettiamo l'impianto e ne seguiamo la gestione.", + ], + image: { src: '/images/aree/industria.svg', alt: 'Illustrazione di un impianto di trattamento acque industriale', width: 1200, height: 800 }, + benefits: [ + { title: 'Macchinari protetti', text: 'Meno incrostazioni e corrosione su caldaie e scambiatori.' }, + { title: 'Qualità costante', text: 'Parametri controllati per processi ripetibili.' }, + { title: 'Manutenzione programmata', text: 'Contratti di assistenza con interventi pianificati.' }, + ], + steps: [ + { title: 'Analisi', text: "Caratteristiche dell'acqua e requisiti del processo." }, + { title: 'Progetto', text: 'Dimensionamento e scelta delle tecnologie.' }, + { title: 'Realizzazione e gestione', text: 'Installazione, avviamento e assistenza.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Fornite anche i prodotti di consumo?', + answer: 'Sì, sale, cartucce, membrane e prodotti di condizionamento, con consegne programmate.', + }, + ], + configuratorPath: 'azienda', +}) diff --git a/apps/public/src/content/solutions/industria/fotovoltaico-industriale.ts b/apps/public/src/content/solutions/industria/fotovoltaico-industriale.ts new file mode 100644 index 0000000..af31323 --- /dev/null +++ b/apps/public/src/content/solutions/industria/fotovoltaico-industriale.ts @@ -0,0 +1,37 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'fotovoltaico-industriale', + area: 'industria', + order: 2, + title: 'Fotovoltaico industriale', + tagline: 'Impianti su capannoni e a terra per abbattere i costi energetici.', + description: + 'Fotovoltaico per aziende su capannoni, pensiline e a terra: analisi dei consumi, progetto, autorizzazioni, installazione e manutenzione.', + intro: [ + 'Le aziende consumano soprattutto di giorno: il momento in cui il fotovoltaico produce. Per questo il rientro dell’investimento è spesso rapido.', + 'Gestiamo progetto, autorizzazioni, sicurezza in cantiere e connessione, con un piano di manutenzione per mantenere la produzione nel tempo.', + ], + image: { src: '/images/aree/industria.svg', alt: 'Illustrazione di pannelli fotovoltaici su un capannone', width: 1200, height: 800 }, + benefits: [ + { title: 'Autoconsumo elevato', text: 'Produzione e consumi coincidono durante l’orario di lavoro.' }, + { title: 'Costi prevedibili', text: 'Una parte dell’energia a costo fisso per decenni.' }, + { title: 'Coperture valorizzate', text: 'Anche con rimozione dell’amianto, dove presente.' }, + ], + steps: [ + { title: 'Analisi dei consumi', text: 'Curve di carico e contratti di fornitura.' }, + { title: 'Progetto e autorizzazioni', text: 'Strutture, sicurezza e pratiche.' }, + { title: 'Cantiere', text: 'Installazione pianificata con l’attività produttiva.' }, + { title: 'O&M', text: 'Monitoraggio, pulizia e manutenzione.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Si può installare su una copertura in amianto?', + answer: 'Prima va bonificata: possiamo coordinare la rimozione e il rifacimento della copertura insieme all’impianto.', + }, + ], + configuratorPath: 'azienda', +}) diff --git a/apps/public/src/content/solutions/mobilita/ricarica-aziendale.ts b/apps/public/src/content/solutions/mobilita/ricarica-aziendale.ts new file mode 100644 index 0000000..8c724db --- /dev/null +++ b/apps/public/src/content/solutions/mobilita/ricarica-aziendale.ts @@ -0,0 +1,36 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'ricarica-aziendale', + area: 'mobilita', + order: 2, + title: 'Ricarica aziendale', + tagline: 'Stazioni di ricarica per flotte, dipendenti e clienti.', + description: + 'Stazioni di ricarica per aziende, hotel e attività commerciali: progetto, installazione, gestione degli accessi e dei consumi.', + intro: [ + 'Offrire la ricarica a flotte, dipendenti o clienti è un servizio sempre più richiesto.', + 'Progettiamo l’infrastruttura sulla potenza disponibile, con gestione degli accessi e rendicontazione dei consumi.', + ], + image: { src: '/images/aree/mobilita.svg', alt: 'Illustrazione di stazioni di ricarica in un parcheggio aziendale', width: 1200, height: 800 }, + benefits: [ + { title: 'Bilanciamento dei carichi', text: 'Più punti di ricarica senza aumentare troppo la potenza.' }, + { title: 'Accessi e consumi', text: 'Badge o app, report per utente.' }, + { title: 'Scalabile', text: 'Si parte con pochi punti e si amplia nel tempo.' }, + ], + steps: [ + { title: 'Analisi', text: 'Esigenze, numero di veicoli e potenza disponibile.' }, + { title: 'Progetto', text: 'Posizione delle stazioni e infrastruttura elettrica.' }, + { title: 'Installazione e gestione', text: 'Posa, configurazione e assistenza.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Si può far pagare la ricarica?', + answer: 'Sì, con piattaforme di gestione che permettono tariffe e pagamenti per utenti esterni.', + }, + ], + configuratorPath: 'azienda', +}) diff --git a/apps/public/src/content/solutions/mobilita/wallbox.ts b/apps/public/src/content/solutions/mobilita/wallbox.ts new file mode 100644 index 0000000..473a091 --- /dev/null +++ b/apps/public/src/content/solutions/mobilita/wallbox.ts @@ -0,0 +1,35 @@ +// ⚠️ TESTO SEGNAPOSTO: da sostituire con i testi forniti dall'azienda (poi placeholder: false). +import { defineSolution } from '../../types' + +export default defineSolution({ + placeholder: true, + slug: 'wallbox', + area: 'mobilita', + order: 1, + title: 'Wallbox domestiche', + tagline: 'Ricarica a casa, anche integrata con il fotovoltaico.', + description: + 'Wallbox per ricaricare l’auto elettrica a casa: verifica della potenza, installazione certificata e ricarica con l’energia del fotovoltaico.', + intro: [ + 'Una wallbox ricarica l’auto in sicurezza e più velocemente di una presa domestica.', + 'Con la gestione dinamica della potenza la ricarica si adatta ai consumi di casa; con il fotovoltaico può usare solo l’energia in eccesso.', + ], + image: { src: '/images/aree/mobilita.svg', alt: 'Illustrazione di una wallbox domestica', width: 1200, height: 800 }, + benefits: [ + { title: 'Sicura', text: 'Protezioni dedicate e installazione a norma.' }, + { title: 'Intelligente', text: 'Programmazione oraria e controllo da app.' }, + { title: 'Solare', text: 'Ricarica con il surplus del fotovoltaico.' }, + ], + steps: [ + { title: 'Verifica', text: 'Contatore, potenza disponibile e distanza dal quadro.' }, + { title: 'Installazione', text: 'Linea dedicata, protezioni e wallbox.' }, + { title: 'Configurazione', text: 'App, limiti di potenza e programmazione.' }, + ], + relatedProducts: [], + faq: [ + { + question: 'Quanto tempo ci vuole per una ricarica completa?', + answer: 'Dipende dalla batteria dell’auto e dalla potenza della wallbox: con 7,4 kW una batteria da 50 kWh si ricarica in circa 7 ore.', + }, + ], +}) diff --git a/apps/public/src/content/types.ts b/apps/public/src/content/types.ts new file mode 100644 index 0000000..ad5f349 --- /dev/null +++ b/apps/public/src/content/types.ts @@ -0,0 +1,115 @@ +/** + * Tipi dei file di contenuto (src/content). Ogni file esporta `default` con l'helper `define…` corrispondente, + * così TypeScript verifica la forma dei contenuti. Dettagli e procedura in CLAUDE.md, sezione "Contenuti". + */ + +/** Immagine servita da public/ (percorso assoluto, es. "/images/aree/acqua.svg"). */ +export interface ContentImage { + src: string + /** Testo alternativo descrittivo (accessibilità e SEO); "" solo per immagini decorative. */ + alt: string + /** Dimensioni intrinseche (rapporto d'aspetto per NgOptimizedImage). */ + width: number + height: number +} + +export interface Benefit { + title: string + text: string +} + +export interface Step { + title: string + text: string +} + +export interface Faq { + question: string + /** Testo semplice (va anche nel JSON-LD FAQPage). */ + answer: string +} + +/** Percorsi del configuratore (domanda "percorso" in apps/api/src/configurator/definition.ts). */ +export type ConfiguratorPath = 'acqua' | 'fotovoltaico' | 'pompa_calore' | 'azienda' + +/** Campi comuni di macro-aree e soluzioni. */ +interface SolutionLike { + /** + * true finché i testi sono segnaposto: l'azienda deve fornire quelli definitivi. + * In sviluppo le pagine con contenuti segnaposto mostrano un'etichetta "Testo segnaposto". + */ + placeholder: boolean + /** Titolo della pagina (h1) e base del . */ + title: string + /** Meta description (≈ 120-160 caratteri). */ + description: string + /** Paragrafi introduttivi. */ + intro: string[] + image: ContentImage + benefits: Benefit[] + /** "Come funziona": passi in ordine. */ + steps: Step[] + /** Handle dei prodotti Medusa correlati (card dallo shop; un handle inesistente viene semplicemente omesso). */ + relatedProducts: string[] + faq: Faq[] + /** Percorso del configuratore preselezionato nella CTA (se esiste un percorso adatto). */ + configuratorPath?: ConfiguratorPath +} + +/** Macro-area: file src/content/areas/<slug>.ts (lo slug è il nome del file). */ +export interface AreaContent extends SolutionLike { + slug: string + /** Etichetta del menu. */ + label: string + /** Ordine nel menu e nelle liste. */ + order: number + /** Riga breve per card e menu. */ + tagline: string +} + +/** Soluzione: file src/content/solutions/<area>/<slug>.ts (cartella = area, nome del file = slug). */ +export interface SolutionContent extends SolutionLike { + slug: string + area: string + order: number + /** Riga breve per le card. */ + tagline: string +} + +export interface ContentSection { + heading: string + paragraphs: string[] + items?: string[] +} + +/** Pagina azienda: file src/content/company/<slug>.ts (URL /azienda/<slug>; "chi-siamo" è /azienda). */ +export interface CompanyPageContent { + placeholder: boolean + slug: string + order: number + title: string + description: string + lead: string + sections: ContentSection[] + /** Pulsante finale facoltativo. */ + cta?: { label: string; path: string; query?: Record<string, string> } +} + +/** Realizzazione (galleria /realizzazioni, file src/content/projects.ts). */ +export interface ProjectContent { + placeholder: boolean + id: string + title: string + /** Slug della macro-area. */ + area: string + location: string + year: number + summary: string + image: ContentImage + /** Mostrata in home. */ + featured?: boolean +} + +export const defineArea = (area: AreaContent): AreaContent => area +export const defineSolution = (solution: SolutionContent): SolutionContent => solution +export const defineCompanyPage = (page: CompanyPageContent): CompanyPageContent => page diff --git a/apps/public/src/environments/environment.ts b/apps/public/src/environments/environment.ts new file mode 100644 index 0000000..80b1a3f --- /dev/null +++ b/apps/public/src/environments/environment.ts @@ -0,0 +1,17 @@ +/** + * Valori di default (sviluppo locale). In produzione il server SSR li sovrascrive con le variabili + * d'ambiente (SITE_URL, PUBLIC_API_URL, PRIVATE_APP_URL, SITE_INDEXABLE: vedi src/server/runtime-config.ts) + * e li passa al browser tramite TransferState: la stessa build vale per qualunque dominio. + */ +export const environment = { + /** URL pubblico del sito (canonical, Open Graph, sitemap), senza slash finale. */ + siteUrl: 'http://localhost:4200', + /** URL dell'API Fastify visto dal browser. */ + apiUrl: 'http://localhost:3000', + /** Area riservata (apps/private). */ + privateAppUrl: 'http://localhost:4300', + /** false = noindex su tutte le pagine e robots.txt "Disallow: /" (sviluppo, staging). */ + indexable: false, + /** Chiave pubblicabile Stripe: in sviluppo si imposta con STRIPE_PUBLISHABLE_KEY (vedi .env.example). */ + stripePublishableKey: '', +} diff --git a/apps/public/src/index.html b/apps/public/src/index.html new file mode 100644 index 0000000..1b4d5e8 --- /dev/null +++ b/apps/public/src/index.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="it"> +<head> + <meta charset="utf-8"> + <title>ITI TECHNOLOGY + + + + + + + + + + + diff --git a/apps/public/src/main.server.ts b/apps/public/src/main.server.ts new file mode 100644 index 0000000..723e001 --- /dev/null +++ b/apps/public/src/main.server.ts @@ -0,0 +1,8 @@ +import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => + bootstrapApplication(App, config, context); + +export default bootstrap; diff --git a/apps/public/src/main.ts b/apps/public/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/apps/public/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/apps/public/src/server.ts b/apps/public/src/server.ts new file mode 100644 index 0000000..8f64ec5 --- /dev/null +++ b/apps/public/src/server.ts @@ -0,0 +1,141 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node' +import { isDevMode } from '@angular/core' +import compression from 'compression' +import express from 'express' +import { staticSitemapPaths } from './app/core/site' +import { applyNonce, buildCsp, generateNonce } from './server/csp' +import { loadServerConfig } from './server/runtime-config' +import { buildRobotsTxt, buildSitemapXml, type ShopSitemapData, type SitemapData, sitemapEntries } from './server/sitemap' + +// .env facoltativo nella cartella di avvio (sviluppo); in produzione le variabili arrivano da Docker +if (existsSync('.env')) process.loadEnvFile('.env') + +const config = loadServerConfig() +const browserDistFolder = join(import.meta.dirname, '../browser') + +const app = express() +app.disable('x-powered-by') +// gzip di HTML, JS, CSS, SVG, XML (Caddy in produzione non ricomprime le risposte già compresse) +app.use(compression()) +const angularApp = new AngularNodeAppEngine() + +// --- robots.txt e sitemap.xml (prima del rendering Angular) --- + +app.get('/robots.txt', (_req, res) => { + res.type('text/plain').set('cache-control', 'public, max-age=3600').send(buildRobotsTxt(config.siteUrl, config.indexable)) +}) + +const SITEMAP_TTL_MS = 5 * 60_000 +const SITEMAP_RETRY_MS = 60_000 +let sitemapCache: { xml: string; expiresAt: number } | null = null + +/** Dati del blog dall'API (GET /sitemap-data); null se l'API non risponde: la sitemap esce con le sole pagine statiche. */ +async function fetchSitemapData(): Promise { + try { + const res = await fetch(`${config.apiInternalUrl}/sitemap-data`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) throw new Error(`status ${res.status}`) + return (await res.json()) as SitemapData + } catch (err) { + console.warn(`[sitemap] /sitemap-data non disponibile, uso solo le pagine statiche: ${(err as Error).message}`) + return null + } +} + +const SHOP_SITEMAP_PAGE = 50 +const SHOP_SITEMAP_MAX = 5000 + +async function getJson(path: string): Promise { + const res = await fetch(`${config.apiInternalUrl}${path}`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) throw new Error(`${path}: status ${res.status}`) + return (await res.json()) as T +} + +/** + * Handle di prodotti e categorie dello shop (GET /shop/products a pagine da 50, GET /shop/categories, prezzi + * pubblici); null se l'API o Medusa non rispondono: la sitemap esce senza lo shop. + */ +async function fetchShopSitemapData(): Promise { + try { + const products: string[] = [] + for (let offset = 0; offset < SHOP_SITEMAP_MAX; offset += SHOP_SITEMAP_PAGE) { + const page = await getJson<{ items: { handle: string }[]; total: number }>( + `/shop/products?limit=${SHOP_SITEMAP_PAGE}&offset=${offset}`, + ) + products.push(...page.items.map((p) => p.handle)) + if (offset + SHOP_SITEMAP_PAGE >= page.total) break + } + const categories = await getJson<{ handle: string }[]>('/shop/categories') + return { products, categories: categories.map((c) => c.handle) } + } catch (err) { + console.warn(`[sitemap] shop non disponibile, sitemap senza prodotti: ${(err as Error).message}`) + return null + } +} + +app.get('/sitemap.xml', async (_req, res, next) => { + try { + if (!sitemapCache || sitemapCache.expiresAt < Date.now()) { + const [data, shop] = await Promise.all([fetchSitemapData(), fetchShopSitemapData()]) + const xml = buildSitemapXml(config.siteUrl, sitemapEntries(staticSitemapPaths(), data, shop)) + // Con l'API (o Medusa) giù si riprova prima, per non servire a lungo una sitemap incompleta + sitemapCache = { xml, expiresAt: Date.now() + (data && shop ? SITEMAP_TTL_MS : SITEMAP_RETRY_MS) } + } + res.type('application/xml').set('cache-control', 'public, max-age=300').send(sitemapCache.xml) + } catch (err) { + next(err) + } +}) + +// --- File statici della build --- +// Con hash nel nome (main-XXXX.js, chunk-…, styles-…): immutabili, cache di un anno. Gli altri (immagini, favicon, +// logo da public/) possono essere sostituiti mantenendo il nome: cache breve. +const HASHED = /-[A-Za-z0-9_-]{8}\.(js|mjs|css)$/ +app.use( + express.static(browserDistFolder, { + index: false, + redirect: false, + setHeaders: (res, path) => { + res.setHeader('cache-control', HASHED.test(path) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600') + }, + }), +) + +// --- Tutto il resto: rendering Angular --- +// CSP con un nonce per risposta (src/server/csp.ts). Non con `ng serve`: il dev server inietta i suoi script. +const cspEnabled = !isDevMode() + +/** Pagina HTML con il nonce al posto del segnaposto e la CSP corrispondente; le altre risposte passano invariate. */ +async function withCspNonce(response: Response): Promise { + if (!response.headers.get('content-type')?.startsWith('text/html')) return response + const nonce = generateNonce() + const headers = new Headers(response.headers) + headers.delete('content-length') + if (cspEnabled) headers.set('content-security-policy', buildCsp(nonce, config)) + return new Response(applyNonce(await response.text(), nonce), { status: response.status, statusText: response.statusText, headers }) +} + +app.use((req, res, next) => { + angularApp + .handle(req) + .then(async (response) => (response ? writeResponseToNodeResponse(await withCspNonce(response), res) : next())) + .catch(next) +}) + +/** Avvio diretto (node dist/public/server/server.mjs) o con PM2; con `ng serve` lo usa il dev server. */ +if (isMainModule(import.meta.url) || process.env['pm_id']) { + const port = process.env['PORT'] || 4000 + app.listen(port, (error) => { + if (error) throw error + console.log(`Sito pubblico (SSR) su http://localhost:${port} → ${config.siteUrl}, API ${config.apiInternalUrl}`) + }) +} + +/** Request handler usato dalla CLI Angular (dev server e build). */ +export const reqHandler = createNodeRequestHandler(app) diff --git a/apps/public/src/server/csp.spec.ts b/apps/public/src/server/csp.spec.ts new file mode 100644 index 0000000..92ae85f --- /dev/null +++ b/apps/public/src/server/csp.spec.ts @@ -0,0 +1,29 @@ +import { applyNonce, buildCsp, CSP_NONCE_PLACEHOLDER, generateNonce } from './csp' + +describe('CSP con nonce', () => { + it('nonce casuale, diverso a ogni risposta', () => { + const a = generateNonce() + expect(a).toMatch(/^[A-Za-z0-9+/]{22}==$/) + expect(generateNonce()).not.toBe(a) + }) + + it('sostituisce il segnaposto su tutti gli script e stili', () => { + const html = + `` + + `` + + `` + const out = applyNonce(html, 'abc123') + expect(out).not.toContain(CSP_NONCE_PLACEHOLDER) + expect(out.match(/abc123/g)).toHaveLength(3) + }) + + it('script solo da self, nonce e Stripe; niente unsafe-inline negli script', () => { + const csp = buildCsp('abc123', { apiUrl: 'https://api.example.com/', mediaUrl: 'https://media.example.com/shop' }) + const scriptSrc = csp.split('; ').find((d) => d.startsWith('script-src')) + expect(scriptSrc).toBe("script-src 'self' 'nonce-abc123' https://js.stripe.com https://*.js.stripe.com") + expect(csp).toContain("connect-src 'self' https://api.example.com https://api.stripe.com") + expect(csp).toContain('https://media.example.com https://tile.openstreetmap.org') + expect(csp).toContain("frame-ancestors 'none'") + expect(csp).toContain("object-src 'none'") + }) +}) diff --git a/apps/public/src/server/csp.ts b/apps/public/src/server/csp.ts new file mode 100644 index 0000000..817c0fd --- /dev/null +++ b/apps/public/src/server/csp.ts @@ -0,0 +1,52 @@ +/** + * Content-Security-Policy del sito con un nonce per risposta (niente 'unsafe-inline' negli script). + * + * Gli script inline dell'HTML sono tutti di Angular: il contratto dell'event replay (`ng-event-dispatch-contract`), + * il suo avvio (`__jsaction_bootstrap`) e lo swap dei CSS critici (beasties). `src/index.html` ha + * `ngCspNonce="__CSP_NONCE__"` sul componente radice: + * - la build mette `nonce="__CSP_NONCE__"` su script e stili inline dell'index e lo passa a beasties; + * - durante l'SSR `CSP_NONCE` di Angular legge lo stesso attributo e lo usa per lo script di avvio; + * - il server Express sostituisce il segnaposto con un nonce casuale per ogni risposta (`applyNonce`) e manda + * la stessa intestazione CSP (`buildCsp`). Nel browser Angular legge il nonce vero da `ngCspNonce`. + * Il contenuto HTML degli articoli del blog è sanitizzato dall'API (niente `