From 30e28cb0196602f881c460fbcb4a2016d44a9636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 15:01:56 +0100 Subject: [PATCH] feat(front-end): seo --- frontend/public/robots.txt | 21 +++ frontend/public/sitemap.xml | 144 ++++++++++++++++++ frontend/src/app/core/services/seo.service.ts | 121 +++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 frontend/public/robots.txt create mode 100644 frontend/public/sitemap.xml create mode 100644 frontend/src/app/core/services/seo.service.ts diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6bd7b9c --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,21 @@ +User-agent: * +Allow: / + +Disallow: /admin +Disallow: /admin/ +Disallow: /*/admin +Disallow: /*/admin/ +Disallow: /order/ +Disallow: /*/order/ +Disallow: /co/ +Disallow: /*/co/ +Disallow: /checkout +Disallow: /checkout/ +Disallow: /*/checkout +Disallow: /*/checkout/ +Disallow: /shop +Disallow: /shop/ +Disallow: /*/shop +Disallow: /*/shop/ + +Sitemap: https://3d-fab.ch/sitemap.xml diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 0000000..a1195d2 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,144 @@ + + + + https://3d-fab.ch/it + + + + + + weekly + 1.0 + + + https://3d-fab.ch/it/calculator/basic + + + + + + weekly + 0.9 + + + https://3d-fab.ch/it/calculator/advanced + + + + + + weekly + 0.8 + + + https://3d-fab.ch/it/about + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/contact + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/privacy + + + + + + yearly + 0.4 + + + https://3d-fab.ch/it/terms + + + + + + yearly + 0.4 + + diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts new file mode 100644 index 0000000..70505ad --- /dev/null +++ b/frontend/src/app/core/services/seo.service.ts @@ -0,0 +1,121 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; +import { Title, Meta } from '@angular/platform-browser'; +import { + ActivatedRouteSnapshot, + NavigationEnd, + Router, +} from '@angular/router'; +import { filter } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class SeoService { + private readonly defaultTitle = '3D fab | Stampa 3D su misura'; + private readonly defaultDescription = + 'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; + private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); + + constructor( + private router: Router, + private titleService: Title, + private metaService: Meta, + @Inject(DOCUMENT) private document: Document, + ) { + this.applyRouteSeo(this.router.routerState.snapshot.root); + this.router.events + .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) + .subscribe(() => { + this.applyRouteSeo(this.router.routerState.snapshot.root); + }); + } + + private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { + const mergedData = this.getMergedRouteData(rootSnapshot); + const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; + const description = + this.asString(mergedData['seoDescription']) ?? this.defaultDescription; + const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; + + this.titleService.setTitle(title); + this.metaService.updateTag({ name: 'description', content: description }); + this.metaService.updateTag({ name: 'robots', content: robots }); + this.metaService.updateTag({ property: 'og:title', content: title }); + this.metaService.updateTag({ + property: 'og:description', + content: description, + }); + this.metaService.updateTag({ property: 'og:type', content: 'website' }); + this.metaService.updateTag({ name: 'twitter:card', content: 'summary' }); + + const cleanPath = this.getCleanPath(this.router.url); + const canonical = `${this.document.location.origin}${cleanPath}`; + this.metaService.updateTag({ property: 'og:url', content: canonical }); + this.updateCanonicalTag(canonical); + this.updateLangAndAlternates(cleanPath); + } + + private getMergedRouteData(snapshot: ActivatedRouteSnapshot): Record { + const merged: Record = {}; + let cursor: ActivatedRouteSnapshot | null = snapshot; + while (cursor) { + Object.assign(merged, cursor.data ?? {}); + cursor = cursor.firstChild; + } + return merged; + } + + private asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; + } + + private getCleanPath(url: string): string { + const path = (url || '/').split('?')[0].split('#')[0]; + return path || '/'; + } + + private updateCanonicalTag(url: string): void { + let link = this.document.head.querySelector( + 'link[rel="canonical"]', + ) as HTMLLinkElement | null; + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + link.setAttribute('href', url); + } + + private updateLangAndAlternates(path: string): void { + const segments = path.split('/').filter(Boolean); + const firstSegment = segments[0]?.toLowerCase(); + const hasLang = Boolean(firstSegment && this.supportedLangs.has(firstSegment)); + const lang = hasLang ? firstSegment : 'it'; + const suffixSegments = hasLang ? segments.slice(1) : segments; + const suffix = suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; + + this.document.documentElement.lang = lang; + + this.document.head + .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') + .forEach((node) => node.remove()); + + for (const alt of ['it', 'en', 'de', 'fr']) { + this.appendAlternateLink(alt, `${this.document.location.origin}/${alt}${suffix}`); + } + this.appendAlternateLink( + 'x-default', + `${this.document.location.origin}/it${suffix}`, + ); + } + + private appendAlternateLink(hreflang: string, href: string): void { + const link = this.document.createElement('link'); + link.setAttribute('rel', 'alternate'); + link.setAttribute('hreflang', hreflang); + link.setAttribute('href', href); + link.setAttribute('data-seo-managed', 'true'); + this.document.head.appendChild(link); + } +}