Compare commits
5 Commits
cac534ccbb
...
a3cd451575
| Author | SHA1 | Date | |
|---|---|---|---|
| a3cd451575 | |||
|
|
6f700c923a | ||
| 46fd59ed71 | |||
| ba49463ee7 | |||
| 576380e9a0 |
@@ -15,18 +15,8 @@ const appChildRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||
data: {
|
||||
seoTitleByLang: {
|
||||
it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
|
||||
en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab',
|
||||
de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab',
|
||||
fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab',
|
||||
},
|
||||
seoDescriptionByLang: {
|
||||
it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.',
|
||||
en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.',
|
||||
de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.',
|
||||
fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.",
|
||||
},
|
||||
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -36,9 +26,8 @@ const appChildRoutes: Routes = [
|
||||
(m) => m.CALCULATOR_ROUTES,
|
||||
),
|
||||
data: {
|
||||
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
|
||||
seoDescription:
|
||||
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
|
||||
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -46,9 +35,8 @@ const appChildRoutes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
||||
data: {
|
||||
seoTitle: 'Shop 3D fab',
|
||||
seoDescription:
|
||||
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
|
||||
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -56,9 +44,8 @@ const appChildRoutes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
||||
data: {
|
||||
seoTitle: 'Chi siamo | 3D fab',
|
||||
seoDescription:
|
||||
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
||||
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
/* {
|
||||
@@ -68,9 +55,8 @@ const appChildRoutes: Routes = [
|
||||
(m) => m.MaterialsPageComponent,
|
||||
),
|
||||
data: {
|
||||
seoTitle: 'Qualita e Materiali | 3D fab',
|
||||
seoDescription:
|
||||
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
|
||||
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
|
||||
},
|
||||
},*/
|
||||
{
|
||||
@@ -78,9 +64,8 @@ const appChildRoutes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||
data: {
|
||||
seoTitle: 'Contatti | 3D fab',
|
||||
seoDescription:
|
||||
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
|
||||
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -90,7 +75,8 @@ const appChildRoutes: Routes = [
|
||||
(m) => m.CheckoutComponent,
|
||||
),
|
||||
data: {
|
||||
seoTitle: 'Checkout | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||
seoRobots: 'noindex, nofollow',
|
||||
},
|
||||
},
|
||||
@@ -101,7 +87,8 @@ const appChildRoutes: Routes = [
|
||||
(m) => m.CheckoutComponent,
|
||||
),
|
||||
data: {
|
||||
seoTitle: 'Checkout | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||
seoRobots: 'noindex, nofollow',
|
||||
},
|
||||
},
|
||||
@@ -110,7 +97,8 @@ const appChildRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||
data: {
|
||||
seoTitle: 'Ordine | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||
seoRobots: 'noindex, nofollow',
|
||||
},
|
||||
},
|
||||
@@ -119,7 +107,8 @@ const appChildRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||
data: {
|
||||
seoTitle: 'Ordine | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||
seoRobots: 'noindex, nofollow',
|
||||
},
|
||||
},
|
||||
@@ -133,7 +122,8 @@ const appChildRoutes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
||||
data: {
|
||||
seoTitle: 'Admin | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
|
||||
seoRobots: 'noindex, nofollow',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject, REQUEST } from '@angular/core';
|
||||
|
||||
type RequestLike = {
|
||||
protocol?: string;
|
||||
get?: (name: string) => string | undefined;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
import {
|
||||
RequestLike,
|
||||
resolveRequestOrigin,
|
||||
} from '../../../core/request-origin';
|
||||
|
||||
function isAbsoluteUrl(url: string): boolean {
|
||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function resolveOrigin(request: RequestLike | null): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host =
|
||||
request.get?.('host') ??
|
||||
firstHeaderValue(request.headers?.['host']) ??
|
||||
firstHeaderValue(request.headers?.['x-forwarded-host']);
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forwardedProtoRaw = firstHeaderValue(
|
||||
request.headers?.['x-forwarded-proto'],
|
||||
);
|
||||
const forwardedProto = forwardedProtoRaw
|
||||
?.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.find(Boolean);
|
||||
const protocol = forwardedProto || request.protocol || 'http';
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(url: string): string {
|
||||
const withoutDot = url.replace(/^\.\//, '');
|
||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||
@@ -53,7 +20,7 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
}
|
||||
|
||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
||||
const origin = resolveOrigin(request);
|
||||
const origin = resolveRequestOrigin(request);
|
||||
if (!origin) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
</div>
|
||||
|
||||
<div class="col links">
|
||||
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
|
||||
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
|
||||
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
|
||||
<a [routerLink]="languageService.localizedPath('/privacy')">{{
|
||||
"FOOTER.PRIVACY" | translate
|
||||
}}</a>
|
||||
<a [routerLink]="languageService.localizedPath('/terms')">{{
|
||||
"FOOTER.TERMS" | translate
|
||||
}}</a>
|
||||
<a [routerLink]="languageService.localizedPath('/contact')">{{
|
||||
"FOOTER.CONTACT" | translate
|
||||
}}</a>
|
||||
</div>
|
||||
|
||||
<div class="col social">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LanguageService } from '../services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss'],
|
||||
})
|
||||
export class FooterComponent {}
|
||||
export class FooterComponent {
|
||||
readonly languageService = inject(LanguageService);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<header class="navbar">
|
||||
<div class="container navbar-inner">
|
||||
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
|
||||
<a [routerLink]="langService.localizedPath('/')" class="brand"
|
||||
>3D <span class="highlight">fab</span></a
|
||||
>
|
||||
|
||||
<div
|
||||
class="mobile-toggle"
|
||||
@@ -14,27 +16,33 @@
|
||||
|
||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||
<a
|
||||
routerLink="/"
|
||||
[routerLink]="langService.localizedPath('/')"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
(click)="closeMenu()"
|
||||
>{{ "NAV.HOME" | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/calculator/basic"
|
||||
[routerLink]="langService.localizedPath('/calculator/basic')"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
(click)="closeMenu()"
|
||||
>{{ "NAV.CALCULATOR" | translate }}</a
|
||||
>
|
||||
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
|
||||
"NAV.SHOP" | translate
|
||||
}}</a>
|
||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
|
||||
"NAV.ABOUT" | translate
|
||||
}}</a>
|
||||
<a
|
||||
routerLink="/contact"
|
||||
[routerLink]="langService.localizedPath('/shop')"
|
||||
routerLinkActive="active"
|
||||
(click)="closeMenu()"
|
||||
>{{ "NAV.SHOP" | translate }}</a
|
||||
>
|
||||
<a
|
||||
[routerLink]="langService.localizedPath('/about')"
|
||||
routerLinkActive="active"
|
||||
(click)="closeMenu()"
|
||||
>{{ "NAV.ABOUT" | translate }}</a
|
||||
>
|
||||
<a
|
||||
[routerLink]="langService.localizedPath('/contact')"
|
||||
routerLinkActive="active"
|
||||
(click)="closeMenu()"
|
||||
>{{ "NAV.CONTACT" | translate }}</a
|
||||
@@ -82,7 +90,10 @@
|
||||
}
|
||||
</select>
|
||||
|
||||
<div class="icon-placeholder" routerLink="/admin">
|
||||
<div
|
||||
class="icon-placeholder"
|
||||
[routerLink]="langService.localizedPath('/admin')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
|
||||
@@ -133,7 +133,7 @@ export class NavbarComponent {
|
||||
}
|
||||
|
||||
this.closeCart();
|
||||
this.router.navigate(['/checkout'], {
|
||||
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
|
||||
queryParams: {
|
||||
session: sessionId,
|
||||
},
|
||||
|
||||
@@ -103,4 +103,15 @@ describe('LanguageService', () => {
|
||||
'/de/calculator?session=abc&mode=advanced',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds localized paths for internal links while preserving query and hash', () => {
|
||||
const translate = createTranslateMock();
|
||||
const router = createRouterMock('/de/shop');
|
||||
const service = new LanguageService(translate, router);
|
||||
|
||||
expect(service.localizedPath('/privacy')).toBe('/de/privacy');
|
||||
expect(service.localizedPath('/it/contact?topic=seo#form')).toBe(
|
||||
'/de/contact?topic=seo#form',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,31 @@ export class LanguageService {
|
||||
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
|
||||
}
|
||||
|
||||
localizedPath(path: string): string {
|
||||
const lang = this.selectedLang();
|
||||
const rawValue = String(path ?? '').trim();
|
||||
const normalized = rawValue || '/';
|
||||
const match = normalized.match(/^([^?#]*)([?#].*)?$/);
|
||||
const rawPath = match?.[1] || '/';
|
||||
const suffix = match?.[2] || '';
|
||||
const segments = rawPath.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return `/${lang}${suffix}`;
|
||||
}
|
||||
|
||||
if (this.isSupportedLang(segments[0])) {
|
||||
segments[0] = lang;
|
||||
return `/${segments.join('/')}${suffix}`;
|
||||
}
|
||||
|
||||
if (this.looksLikeLangToken(segments[0])) {
|
||||
return `/${[lang, ...segments.slice(1)].join('/')}${suffix}`;
|
||||
}
|
||||
|
||||
return `/${[lang, ...segments].join('/')}${suffix}`;
|
||||
}
|
||||
|
||||
private ensureLanguageInPath(urlTree: UrlTree): void {
|
||||
const segments = this.getPrimarySegments(urlTree);
|
||||
|
||||
|
||||
135
frontend/src/app/core/services/seo.service.spec.ts
Normal file
135
frontend/src/app/core/services/seo.service.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ActivatedRouteSnapshot, Router } from '@angular/router';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { Subject } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SeoService } from './seo.service';
|
||||
|
||||
describe('SeoService', () => {
|
||||
function createSnapshot(
|
||||
data: Record<string, unknown>,
|
||||
firstChild: ActivatedRouteSnapshot | null = null,
|
||||
): ActivatedRouteSnapshot {
|
||||
return {
|
||||
data,
|
||||
firstChild,
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
}
|
||||
|
||||
function cleanupSeoDom(): void {
|
||||
document.head
|
||||
.querySelectorAll(
|
||||
'link[rel="canonical"], link[rel="alternate"][data-seo-managed="true"], meta[property="og:locale:alternate"][data-seo-managed="true"]',
|
||||
)
|
||||
.forEach((node) => node.remove());
|
||||
document.documentElement.removeAttribute('lang');
|
||||
}
|
||||
|
||||
function createService(options: {
|
||||
url: string;
|
||||
data: Record<string, unknown>;
|
||||
translations: Record<string, string>;
|
||||
}): {
|
||||
meta: jasmine.SpyObj<Meta>;
|
||||
title: jasmine.SpyObj<Title>;
|
||||
} {
|
||||
const events$ = new Subject<unknown>();
|
||||
const title = jasmine.createSpyObj<Title>('Title', ['setTitle']);
|
||||
const meta = jasmine.createSpyObj<Meta>('Meta', ['updateTag']);
|
||||
const translate = {
|
||||
instant: (key: string) => options.translations[key] ?? key,
|
||||
} as TranslateService;
|
||||
const router = {
|
||||
url: options.url,
|
||||
events: events$.asObservable(),
|
||||
routerState: {
|
||||
snapshot: {
|
||||
root: createSnapshot(options.data),
|
||||
},
|
||||
},
|
||||
} as unknown as Router;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const service = new SeoService(router, title, meta, translate, document);
|
||||
|
||||
return { meta, title };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanupSeoDom();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupSeoDom();
|
||||
});
|
||||
|
||||
it('adds the language prefix to canonical and hreflang URLs', () => {
|
||||
const { meta, title } = createService({
|
||||
url: '/privacy?utm=test',
|
||||
data: {
|
||||
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
|
||||
},
|
||||
translations: {
|
||||
'SEO.ROUTES.LEGAL.PRIVACY.TITLE': 'Privacy Policy | 3D fab',
|
||||
'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION': 'Privacy description',
|
||||
},
|
||||
});
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledWith('Privacy Policy | 3D fab');
|
||||
|
||||
const canonical = document.head.querySelector(
|
||||
'link[rel="canonical"]',
|
||||
) as HTMLLinkElement | null;
|
||||
expect(canonical?.getAttribute('href')).toBe(
|
||||
`${document.location.origin}/it/privacy`,
|
||||
);
|
||||
|
||||
const alternates = Array.from(
|
||||
document.head.querySelectorAll(
|
||||
'link[rel="alternate"][data-seo-managed="true"]',
|
||||
),
|
||||
).map((node) => ({
|
||||
hreflang: node.getAttribute('hreflang'),
|
||||
href: node.getAttribute('href'),
|
||||
}));
|
||||
|
||||
expect(alternates).toContain({
|
||||
hreflang: 'en',
|
||||
href: `${document.location.origin}/en/privacy`,
|
||||
});
|
||||
expect(alternates).toContain({
|
||||
hreflang: 'x-default',
|
||||
href: `${document.location.origin}/it/privacy`,
|
||||
});
|
||||
expect(document.documentElement.lang).toBe('it');
|
||||
|
||||
const ogUrlCall = meta.updateTag.calls
|
||||
.allArgs()
|
||||
.find(([tag]) => tag.property === 'og:url');
|
||||
expect(ogUrlCall?.[0].content).toBe(
|
||||
`${document.location.origin}/it/privacy`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves translated route metadata for the active language', () => {
|
||||
const { meta, title } = createService({
|
||||
url: '/en/about',
|
||||
data: {
|
||||
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||
},
|
||||
translations: {
|
||||
'SEO.ROUTES.ABOUT.TITLE': 'About Us | 3D fab',
|
||||
'SEO.ROUTES.ABOUT.DESCRIPTION': 'About description',
|
||||
},
|
||||
});
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledWith('About Us | 3D fab');
|
||||
|
||||
const descriptionCall = meta.updateTag.calls
|
||||
.allArgs()
|
||||
.find(([tag]) => tag.name === 'description');
|
||||
expect(descriptionCall?.[0].content).toBe('About description');
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,28 @@ 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 { TranslateService } from '@ngx-translate/core';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface PageSeoOverride {
|
||||
title?: string | null;
|
||||
titleKey?: string | null;
|
||||
description?: string | null;
|
||||
descriptionKey?: string | null;
|
||||
robots?: string | null;
|
||||
ogTitle?: string | null;
|
||||
ogTitleKey?: string | null;
|
||||
ogDescription?: string | null;
|
||||
ogDescriptionKey?: string | null;
|
||||
}
|
||||
|
||||
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||
type SeoMap = Partial<Record<SupportedLang, string>>;
|
||||
type SeoTextDataKey =
|
||||
| 'seoTitle'
|
||||
| 'seoDescription'
|
||||
| 'ogTitle'
|
||||
| 'ogDescription';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -31,17 +41,27 @@ export class SeoService {
|
||||
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
|
||||
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
|
||||
};
|
||||
private readonly supportedLangs = new Set<SupportedLang>([
|
||||
private readonly supportedLangs: readonly SupportedLang[] = [
|
||||
'it',
|
||||
'en',
|
||||
'de',
|
||||
'fr',
|
||||
]);
|
||||
];
|
||||
private readonly supportedLangSet = new Set<SupportedLang>(
|
||||
this.supportedLangs,
|
||||
);
|
||||
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
|
||||
it: 'it_IT',
|
||||
en: 'en_US',
|
||||
de: 'de_DE',
|
||||
fr: 'fr_FR',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private titleService: Title,
|
||||
private metaService: Meta,
|
||||
private translate: TranslateService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
) {
|
||||
this.applyRouteSeo(this.router.routerState.snapshot.root);
|
||||
@@ -60,15 +80,32 @@ export class SeoService {
|
||||
const cleanPath = this.getCleanPath(this.router.url);
|
||||
const lang = this.resolveLangFromPath(cleanPath);
|
||||
const title =
|
||||
this.asString(override.title) ?? this.defaultTitleByLang[lang];
|
||||
this.resolveOverrideSeoText(override.title, override.titleKey) ??
|
||||
this.defaultTitle(lang);
|
||||
const description =
|
||||
this.asString(override.description) ??
|
||||
this.defaultDescriptionByLang[lang];
|
||||
this.resolveOverrideSeoText(
|
||||
override.description,
|
||||
override.descriptionKey,
|
||||
) ?? this.defaultDescription(lang);
|
||||
const robots = this.asString(override.robots) ?? 'index, follow';
|
||||
const ogTitle = this.asString(override.ogTitle) ?? title;
|
||||
const ogDescription = this.asString(override.ogDescription) ?? description;
|
||||
const ogTitle =
|
||||
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
|
||||
title;
|
||||
const ogDescription =
|
||||
this.resolveOverrideSeoText(
|
||||
override.ogDescription,
|
||||
override.ogDescriptionKey,
|
||||
) ?? description;
|
||||
|
||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
||||
this.applySeoValues(
|
||||
title,
|
||||
description,
|
||||
robots,
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
cleanPath,
|
||||
lang,
|
||||
);
|
||||
}
|
||||
|
||||
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
|
||||
@@ -77,16 +114,24 @@ export class SeoService {
|
||||
const lang = this.resolveLangFromPath(cleanPath);
|
||||
const title =
|
||||
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
|
||||
this.defaultTitleByLang[lang];
|
||||
this.defaultTitle(lang);
|
||||
const description =
|
||||
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
|
||||
this.defaultDescriptionByLang[lang];
|
||||
this.defaultDescription(lang);
|
||||
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
||||
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
||||
const ogDescription =
|
||||
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||
|
||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
||||
this.applySeoValues(
|
||||
title,
|
||||
description,
|
||||
robots,
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
cleanPath,
|
||||
lang,
|
||||
);
|
||||
}
|
||||
|
||||
private applySeoValues(
|
||||
@@ -95,6 +140,8 @@ export class SeoService {
|
||||
robots: string,
|
||||
ogTitle: string,
|
||||
ogDescription: string,
|
||||
cleanPath: string,
|
||||
lang: SupportedLang,
|
||||
): void {
|
||||
this.titleService.setTitle(title);
|
||||
this.metaService.updateTag({ name: 'description', content: description });
|
||||
@@ -105,13 +152,20 @@ export class SeoService {
|
||||
content: ogDescription,
|
||||
});
|
||||
this.metaService.updateTag({ property: 'og:type', content: 'website' });
|
||||
this.metaService.updateTag({ property: 'og:site_name', content: '3D fab' });
|
||||
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
|
||||
this.metaService.updateTag({ name: 'twitter:title', content: ogTitle });
|
||||
this.metaService.updateTag({
|
||||
name: 'twitter:description',
|
||||
content: ogDescription,
|
||||
});
|
||||
|
||||
const cleanPath = this.getCleanPath(this.router.url);
|
||||
const canonical = `${this.document.location.origin}${cleanPath}`;
|
||||
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||
const canonical = `${this.document.location.origin}${canonicalPath}`;
|
||||
this.metaService.updateTag({ property: 'og:url', content: canonical });
|
||||
this.updateCanonicalTag(canonical);
|
||||
this.updateLangAndAlternates(cleanPath);
|
||||
this.updateOpenGraphLocales(lang);
|
||||
this.updateLangAndAlternates(canonicalPath, lang);
|
||||
}
|
||||
|
||||
private getMergedRouteData(
|
||||
@@ -130,9 +184,16 @@ export class SeoService {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private resolveOverrideSeoText(
|
||||
value: string | null | undefined,
|
||||
key: string | null | undefined,
|
||||
): string | undefined {
|
||||
return this.asString(value) ?? this.resolveTranslation(key);
|
||||
}
|
||||
|
||||
private resolveSeoText(
|
||||
routeData: Record<string, unknown>,
|
||||
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription',
|
||||
key: SeoTextDataKey,
|
||||
lang: SupportedLang,
|
||||
): string | undefined {
|
||||
const mapKey = `${key}ByLang`;
|
||||
@@ -148,9 +209,38 @@ export class SeoService {
|
||||
return byLang;
|
||||
}
|
||||
}
|
||||
const translated = this.resolveTranslation(routeData[`${key}Key`]);
|
||||
if (translated) {
|
||||
return translated;
|
||||
}
|
||||
return this.asString(routeData[key]);
|
||||
}
|
||||
|
||||
private resolveTranslation(value: unknown): string | undefined {
|
||||
const key = this.asString(value)?.trim();
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const translated = this.translate.instant(key);
|
||||
return typeof translated === 'string' && translated !== key
|
||||
? translated
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private defaultTitle(lang: SupportedLang): string {
|
||||
return (
|
||||
this.resolveTranslation('SEO.DEFAULT.TITLE') ??
|
||||
this.defaultTitleByLang[lang]
|
||||
);
|
||||
}
|
||||
|
||||
private defaultDescription(lang: SupportedLang): string {
|
||||
return (
|
||||
this.resolveTranslation('SEO.DEFAULT.DESCRIPTION') ??
|
||||
this.defaultDescriptionByLang[lang]
|
||||
);
|
||||
}
|
||||
|
||||
private getCleanPath(url: string): string {
|
||||
const path = (url || '/').split('?')[0].split('#')[0];
|
||||
return path || '/';
|
||||
@@ -160,13 +250,31 @@ export class SeoService {
|
||||
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
|
||||
if (
|
||||
firstSegment &&
|
||||
this.supportedLangs.has(firstSegment as SupportedLang)
|
||||
this.supportedLangSet.has(firstSegment as SupportedLang)
|
||||
) {
|
||||
return firstSegment as SupportedLang;
|
||||
}
|
||||
return 'it';
|
||||
}
|
||||
|
||||
private buildLocalizedPath(path: string, lang: SupportedLang): string {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return `/${lang}`;
|
||||
}
|
||||
|
||||
const firstSegment = segments[0]?.toLowerCase();
|
||||
if (
|
||||
firstSegment &&
|
||||
this.supportedLangSet.has(firstSegment as SupportedLang)
|
||||
) {
|
||||
segments[0] = lang;
|
||||
return `/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
return `/${[lang, ...segments].join('/')}`;
|
||||
}
|
||||
|
||||
private updateCanonicalTag(url: string): void {
|
||||
let link = this.document.head.querySelector(
|
||||
'link[rel="canonical"]',
|
||||
@@ -179,13 +287,31 @@ export class SeoService {
|
||||
link.setAttribute('href', url);
|
||||
}
|
||||
|
||||
private updateLangAndAlternates(path: string): void {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const firstSegment = segments[0]?.toLowerCase();
|
||||
const maybeLang = firstSegment as SupportedLang | undefined;
|
||||
const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
|
||||
const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
|
||||
const suffixSegments = hasLang ? segments.slice(1) : segments;
|
||||
private updateOpenGraphLocales(lang: SupportedLang): void {
|
||||
this.metaService.updateTag({
|
||||
property: 'og:locale',
|
||||
content: this.ogLocaleByLang[lang],
|
||||
});
|
||||
|
||||
this.document.head
|
||||
.querySelectorAll(
|
||||
'meta[property="og:locale:alternate"][data-seo-managed="true"]',
|
||||
)
|
||||
.forEach((node) => node.remove());
|
||||
|
||||
for (const alternateLang of this.supportedLangs) {
|
||||
if (alternateLang === lang) {
|
||||
continue;
|
||||
}
|
||||
this.appendOgLocaleAlternate(this.ogLocaleByLang[alternateLang]);
|
||||
}
|
||||
}
|
||||
|
||||
private updateLangAndAlternates(
|
||||
localizedPath: string,
|
||||
lang: SupportedLang,
|
||||
): void {
|
||||
const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1);
|
||||
const suffix =
|
||||
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
||||
|
||||
@@ -195,7 +321,7 @@ export class SeoService {
|
||||
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
|
||||
for (const alt of ['it', 'en', 'de', 'fr']) {
|
||||
for (const alt of this.supportedLangs) {
|
||||
this.appendAlternateLink(
|
||||
alt,
|
||||
`${this.document.location.origin}/${alt}${suffix}`,
|
||||
@@ -215,4 +341,12 @@ export class SeoService {
|
||||
link.setAttribute('data-seo-managed', 'true');
|
||||
this.document.head.appendChild(link);
|
||||
}
|
||||
|
||||
private appendOgLocaleAlternate(locale: string): void {
|
||||
const meta = this.document.createElement('meta');
|
||||
meta.setAttribute('property', 'og:locale:alternate');
|
||||
meta.setAttribute('content', locale);
|
||||
meta.setAttribute('data-seo-managed', 'true');
|
||||
this.document.head.appendChild(meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@ export const ABOUT_ROUTES: Routes = [
|
||||
path: '',
|
||||
component: AboutPageComponent,
|
||||
data: {
|
||||
seoTitle: 'Chi siamo | 3D fab',
|
||||
seoDescription:
|
||||
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
|
||||
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,9 +8,8 @@ export const CALCULATOR_ROUTES: Routes = [
|
||||
component: CalculatorPageComponent,
|
||||
data: {
|
||||
mode: 'easy',
|
||||
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
|
||||
seoDescription:
|
||||
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
|
||||
seoTitleKey: 'SEO.ROUTES.CALCULATOR.BASIC.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.BASIC.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -18,9 +17,8 @@ export const CALCULATOR_ROUTES: Routes = [
|
||||
component: CalculatorPageComponent,
|
||||
data: {
|
||||
mode: 'advanced',
|
||||
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
|
||||
seoDescription:
|
||||
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
|
||||
seoTitleKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -101,9 +101,11 @@
|
||||
|
||||
<p class="upload-privacy-note">
|
||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
||||
}}</a
|
||||
<a
|
||||
[href]="languageService.localizedPath('/privacy')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
VariantOption,
|
||||
} from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
import { LanguageService } from '../../../../core/services/language.service';
|
||||
|
||||
interface FormItem {
|
||||
file: File;
|
||||
@@ -106,6 +107,7 @@ export class UploadFormComponent implements OnInit {
|
||||
private estimator = inject(QuoteEstimatorService);
|
||||
private fb = inject(FormBuilder);
|
||||
private translate = inject(TranslateService);
|
||||
readonly languageService = inject(LanguageService);
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
|
||||
@@ -120,13 +120,18 @@
|
||||
<input type="checkbox" formControlName="acceptLegal" />
|
||||
<span>
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
<a
|
||||
[href]="languageService.localizedPath('/terms')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||
>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
<a
|
||||
[href]="languageService.localizedPath('/privacy')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ReactiveFormsModule,
|
||||
@@ -11,6 +11,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
|
||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
import { QuoteResult } from '../../services/quote-estimator.service';
|
||||
import { LanguageService } from '../../../../core/services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-details',
|
||||
@@ -30,6 +31,7 @@ export class UserDetailsComponent {
|
||||
quote = input<QuoteResult>();
|
||||
submitOrder = output<any>();
|
||||
cancel = output<void>();
|
||||
readonly languageService = inject(LanguageService);
|
||||
|
||||
form: FormGroup;
|
||||
submitting = signal(false);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
|
||||
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
||||
Servizio CAD
|
||||
{{ "CHECKOUT.CAD_SERVICE" | translate }}
|
||||
<ng-container *ngIf="cadRequestId()">
|
||||
riferito alla richiesta contatto #{{ cadRequestId() }}
|
||||
{{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
|
||||
</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
@@ -204,13 +204,18 @@
|
||||
<span class="ui-checkbox__mark"></span>
|
||||
<span>
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
<a
|
||||
[href]="languageService.localizedPath('/terms')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||
>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
<a
|
||||
[href]="languageService.localizedPath('/privacy')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
@@ -329,7 +334,9 @@
|
||||
</div>
|
||||
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
|
||||
<div class="item-details">
|
||||
<span class="item-name">Servizio CAD</span>
|
||||
<span class="item-name">{{
|
||||
"CHECKOUT.CAD_SERVICE" | translate
|
||||
}}</span>
|
||||
<div class="item-specs-sub">{{ cadHours() }}h</div>
|
||||
</div>
|
||||
<div class="item-price">
|
||||
|
||||
@@ -51,7 +51,7 @@ export class CheckoutComponent implements OnInit {
|
||||
private quoteService = inject(QuoteEstimatorService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private languageService = inject(LanguageService);
|
||||
readonly languageService = inject(LanguageService);
|
||||
|
||||
checkoutForm: FormGroup;
|
||||
sessionId: string | null = null;
|
||||
@@ -147,7 +147,7 @@ export class CheckoutComponent implements OnInit {
|
||||
this.sessionId = params['session'];
|
||||
if (!this.sessionId) {
|
||||
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
|
||||
this.router.navigate(['/']); // Redirect if no session
|
||||
this.router.navigate(['/', this.languageService.selectedLang()]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,11 @@
|
||||
<p class="ui-form-hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
|
||||
<p class="ui-form-hint upload-privacy-note">
|
||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
||||
}}</a
|
||||
<a
|
||||
[href]="languageService.localizedPath('/privacy')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
@@ -161,13 +163,18 @@
|
||||
<span class="ui-checkbox__mark"></span>
|
||||
<span>
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
<a
|
||||
[href]="languageService.localizedPath('/terms')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||
>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
<a
|
||||
[href]="languageService.localizedPath('/privacy')"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -70,7 +70,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
];
|
||||
|
||||
private quoteRequestService = inject(QuoteRequestService);
|
||||
private languageService = inject(LanguageService);
|
||||
readonly languageService = inject(LanguageService);
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
|
||||
@@ -6,9 +6,8 @@ export const CONTACT_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
||||
data: {
|
||||
seoTitle: 'Contatti | 3D fab',
|
||||
seoDescription:
|
||||
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
|
||||
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,15 +16,21 @@
|
||||
{{ "HOME.HERO_SUBTITLE" | translate }}
|
||||
</p>
|
||||
<div class="hero-actions ui-inline-actions ui-inline-actions--wide">
|
||||
<app-button variant="primary" routerLink="/calculator/basic">{{
|
||||
"HOME.BTN_CALCULATE" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="outline" routerLink="/shop">{{
|
||||
"HOME.BTN_SHOP" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="text" routerLink="/contact">{{
|
||||
"HOME.BTN_CONTACT" | translate
|
||||
}}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[routerLink]="languageService.localizedPath('/calculator/basic')"
|
||||
>{{ "HOME.BTN_CALCULATE" | translate }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="outline"
|
||||
[routerLink]="languageService.localizedPath('/shop')"
|
||||
>{{ "HOME.BTN_SHOP" | translate }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="text"
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-swiss-card">
|
||||
@@ -136,13 +142,13 @@
|
||||
<app-button
|
||||
variant="primary"
|
||||
[fullWidth]="true"
|
||||
routerLink="/calculator/basic"
|
||||
[routerLink]="languageService.localizedPath('/calculator/basic')"
|
||||
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="outline"
|
||||
[fullWidth]="true"
|
||||
routerLink="/contact"
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||
>
|
||||
</div>
|
||||
@@ -167,12 +173,16 @@
|
||||
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
|
||||
</ul>
|
||||
<div class="shop-actions ui-inline-actions">
|
||||
<app-button variant="primary" routerLink="/shop">{{
|
||||
"HOME.BTN_DISCOVER" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{
|
||||
"HOME.BTN_REQ_SOLUTION" | translate
|
||||
}}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[routerLink]="languageService.localizedPath('/shop')"
|
||||
>{{ "HOME.BTN_DISCOVER" | translate }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="outline"
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
>{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -237,12 +247,16 @@
|
||||
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
|
||||
</p>
|
||||
<div class="about-actions ui-inline-actions">
|
||||
<app-button variant="primary" routerLink="/about">{{
|
||||
"HOME.SEC_ABOUT_TITLE" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{
|
||||
"HOME.BTN_CONTACT" | translate
|
||||
}}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[routerLink]="languageService.localizedPath('/about')"
|
||||
>{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="outline"
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-media">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import {
|
||||
buildPublicMediaUsageScopeKey,
|
||||
PublicMediaDisplayImage,
|
||||
@@ -69,6 +70,7 @@ const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
|
||||
})
|
||||
export class HomeComponent {
|
||||
private readonly publicMediaService = inject(PublicMediaService);
|
||||
readonly languageService = inject(LanguageService);
|
||||
|
||||
private readonly mediaByUsage = toSignal(
|
||||
this.publicMediaService.getUsageCollections([
|
||||
|
||||
@@ -6,9 +6,8 @@ export const LEGAL_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
||||
data: {
|
||||
seoTitle: 'Privacy Policy | 3D fab',
|
||||
seoDescription:
|
||||
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
|
||||
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -16,9 +15,8 @@ export const LEGAL_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./terms/terms.component').then((m) => m.TermsComponent),
|
||||
data: {
|
||||
seoTitle: 'Termini e condizioni | 3D fab',
|
||||
seoDescription:
|
||||
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
|
||||
seoTitleKey: 'SEO.ROUTES.LEGAL.TERMS.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.LEGAL.TERMS.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -245,7 +245,9 @@ export class OrderComponent implements OnInit {
|
||||
amount: order?.subtotalChf ?? 0,
|
||||
},
|
||||
{
|
||||
label: `Servizio CAD (${order?.cadHours || 0}h)`,
|
||||
label: this.translate.instant('ORDER.CAD_SERVICE', {
|
||||
hours: order?.cadHours || 0,
|
||||
}),
|
||||
amount: order?.cadTotalChf ?? 0,
|
||||
visible: (order?.cadTotalChf ?? 0) > 0,
|
||||
},
|
||||
|
||||
@@ -366,9 +366,12 @@ export class ProductDetailComponent {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/checkout'], {
|
||||
this.router.navigate(
|
||||
['/', this.languageService.selectedLang(), 'checkout'],
|
||||
{
|
||||
queryParams: { session: sessionId },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
priceLabel(): number {
|
||||
|
||||
@@ -239,7 +239,10 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<app-button variant="primary" routerLink="/contact">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
>
|
||||
{{ "NAV.CONTACT" | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ShopPageComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly seoService = inject(SeoService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
readonly languageService = inject(LanguageService);
|
||||
private readonly shopRouteService = inject(ShopRouteService);
|
||||
readonly shopService = inject(ShopService);
|
||||
|
||||
@@ -242,11 +242,14 @@ export class ShopPageComponent {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/checkout'], {
|
||||
this.router.navigate(
|
||||
['/', this.languageService.selectedLang(), 'checkout'],
|
||||
{
|
||||
queryParams: {
|
||||
session: sessionId,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
|
||||
|
||||
@@ -7,30 +7,32 @@ export const SHOP_ROUTES: Routes = [
|
||||
path: '',
|
||||
component: ShopPageComponent,
|
||||
data: {
|
||||
seoTitle: 'Shop 3D fab',
|
||||
seoDescription:
|
||||
'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.',
|
||||
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'p/:productSlug',
|
||||
component: ProductDetailComponent,
|
||||
data: {
|
||||
seoTitle: 'Prodotto | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':categorySlug/:productSlug',
|
||||
component: ProductDetailComponent,
|
||||
data: {
|
||||
seoTitle: 'Prodotto | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':categorySlug',
|
||||
component: ShopPageComponent,
|
||||
data: {
|
||||
seoTitle: 'Categoria Shop | 3D fab',
|
||||
seoTitleKey: 'SEO.ROUTES.SHOP.CATEGORY_TITLE',
|
||||
seoDescriptionKey: 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a routerLink="/contact" class="contact-btn">
|
||||
<a
|
||||
[routerLink]="languageService.localizedPath('/contact')"
|
||||
class="contact-btn"
|
||||
>
|
||||
{{ "LOCATIONS.CONTACT_US" | translate }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LanguageService } from '../../../core/services/language.service';
|
||||
import {
|
||||
AppToggleSelectorComponent,
|
||||
ToggleOption,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
styleUrl: './app-locations.component.scss',
|
||||
})
|
||||
export class AppLocationsComponent {
|
||||
readonly languageService = inject(LanguageService);
|
||||
selectedLocation: 'ticino' | 'bienne' = 'ticino';
|
||||
|
||||
locationOptions: ToggleOption[] = [
|
||||
|
||||
@@ -39,6 +39,72 @@
|
||||
"TERMS": "AGB",
|
||||
"CONTACT": "Kontakt"
|
||||
},
|
||||
"SEO": {
|
||||
"DEFAULT": {
|
||||
"TITLE": "3D fab | 3D-Druck nach Maß",
|
||||
"DESCRIPTION": "3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien."
|
||||
},
|
||||
"ROUTES": {
|
||||
"HOME": {
|
||||
"TITLE": "3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D fab",
|
||||
"DESCRIPTION": "3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien."
|
||||
},
|
||||
"CALCULATOR": {
|
||||
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
|
||||
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
|
||||
"BASIC": {
|
||||
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab",
|
||||
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow."
|
||||
},
|
||||
"ADVANCED": {
|
||||
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab",
|
||||
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing."
|
||||
}
|
||||
},
|
||||
"SHOP": {
|
||||
"TITLE": "3D fab Shop",
|
||||
"DESCRIPTION": "Katalog mit 3D-gedruckten Produkten, technischem Zubehör und sofort einsatzbereiten Lösungen.",
|
||||
"CATEGORY_TITLE": "Shop-Kategorie | 3D fab",
|
||||
"CATEGORY_DESCRIPTION": "Entdecken Sie Produkte dieser Kategorie, verfügbare Varianten und technische 3D-Druck-Lösungen.",
|
||||
"PRODUCT_TITLE": "Produkt | 3D fab",
|
||||
"PRODUCT_DESCRIPTION": "Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit des ausgewählten Produkts im 3D fab Shop."
|
||||
},
|
||||
"MATERIALS": {
|
||||
"TITLE": "Qualität und Materialien | 3D fab",
|
||||
"DESCRIPTION": "Vergleichen Sie 3D-Druckmaterialien mit interaktiven Radar-Charts, technischen Eigenschaften und referenzierten Quellen."
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Über uns | 3D fab",
|
||||
"DESCRIPTION": "Lernen Sie das Team von 3D fab und unser 3D-Druck-Labor für Prototypen, Ersatzteile und maßgeschneiderte Produktionen kennen."
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Kontakt | 3D fab",
|
||||
"DESCRIPTION": "Fordern Sie Informationen, individuelle Angebote oder technischen Support für Ihr 3D-Druck-Projekt an."
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY": {
|
||||
"TITLE": "Datenschutz | 3D fab",
|
||||
"DESCRIPTION": "Datenschutzerklärung von 3D fab: Datenverarbeitung, Zwecke und Kontaktangaben."
|
||||
},
|
||||
"TERMS": {
|
||||
"TITLE": "AGB | 3D fab",
|
||||
"DESCRIPTION": "Allgemeine Geschäftsbedingungen für den 3D-Druck-Service und den Angebotsrechner."
|
||||
}
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout | 3D fab",
|
||||
"DESCRIPTION": "Schließen Sie Ihre Anfrage ab und bestätigen Sie die Daten Ihrer 3D-Druck-Bestellung."
|
||||
},
|
||||
"ORDER": {
|
||||
"TITLE": "Bestellung | 3D fab",
|
||||
"DESCRIPTION": "Prüfen Sie die Zusammenfassung Ihrer Bestellung und den Status Ihrer 3D-Druck-Anfrage."
|
||||
},
|
||||
"ADMIN": {
|
||||
"TITLE": "Admin | 3D fab",
|
||||
"DESCRIPTION": "Geschützter Administrationsbereich von 3D fab."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CALC": {
|
||||
"TITLE": "3D-Angebot berechnen",
|
||||
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
|
||||
@@ -99,6 +165,7 @@
|
||||
"SHOP": {
|
||||
"TITLE": "Technische Lösungen",
|
||||
"SUBTITLE": "Fertige Produkte, die praktische Probleme lösen",
|
||||
"HERO_EYEBROW": "Technischer Shop",
|
||||
"WIP_EYEBROW": "Work in progress",
|
||||
"WIP_TITLE": "Shop im Aufbau",
|
||||
"WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!",
|
||||
@@ -109,6 +176,7 @@
|
||||
"CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.",
|
||||
"ADD_CART": "In den Warenkorb",
|
||||
"ADDING": "Wird hinzugefügt",
|
||||
"ADD_SUCCESS": "Produkt zum Warenkorb hinzugefügt.",
|
||||
"BACK": "Zurück zum Shop",
|
||||
"NOT_FOUND": "Produkt nicht gefunden.",
|
||||
"DETAILS": "Details",
|
||||
@@ -116,14 +184,39 @@
|
||||
"SUCCESS_TITLE": "Zum Warenkorb hinzugefügt",
|
||||
"SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.",
|
||||
"CONTINUE": "Weiter",
|
||||
"VIEW_ALL": "Gesamten Shop ansehen",
|
||||
"CATALOG_LABEL": "Katalog",
|
||||
"CATALOG_TITLE": "Alle Produkte",
|
||||
"CATALOG_META_DESCRIPTION": "Entdecken Sie 3D-gedruckte Produkte, technisches Zubehör und einsatzbereite Lösungen mit demselben Checkout wie im Rechner.",
|
||||
"CUSTOM_PART_CTA": "Nicht gefunden, was Sie suchen? Fordern Sie ein individuelles Teil an.",
|
||||
"CATEGORY_META": "{{count}} Produkte in dieser Kategorie verfügbar",
|
||||
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||
"CATEGORY_PANEL_TITLE": "Kategorien",
|
||||
"SELECTED_CATEGORY": "Ausgewählte Kategorie",
|
||||
"ITEMS_FOUND": "Produkte",
|
||||
"EMPTY_CATEGORY": "Derzeit sind in dieser Kategorie keine Produkte verfügbar.",
|
||||
"FEATURED_KICKER": "Empfohlen",
|
||||
"FEATURED_TITLE": "Produkte, die sich lohnen",
|
||||
"FEATURED_BADGE": "Empfohlen",
|
||||
"HIGHLIGHT_PRODUCTS": "Produkte",
|
||||
"HIGHLIGHT_CART": "Im Warenkorb",
|
||||
"HIGHLIGHT_READY": "Vorschau",
|
||||
"PRICE_FROM": "Preis ab",
|
||||
"MODEL_OPEN": "3D-Ansicht öffnen",
|
||||
"MODEL_CLOSE": "3D-Ansicht schließen",
|
||||
"MODEL_3D": "3D-Vorschau",
|
||||
"MODEL_TITLE": "Modellvorschau",
|
||||
"MODEL_LOADING": "Das 3D-Modell wird geladen.",
|
||||
"MODEL_UNAVAILABLE": "3D-Vorschau nicht verfügbar.",
|
||||
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
||||
"NEXT_IMAGE": "Nächstes Bild",
|
||||
"BREADCRUMB_ROOT": "Shop",
|
||||
"PRICE_LABEL": "Preis",
|
||||
"EXCERPT_FALLBACK": "Produktseite in Vorbereitung.",
|
||||
"SELECT_MATERIAL": "Material",
|
||||
"SELECT_COLOR": "Farbe",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
||||
"VARIANT": "Variante",
|
||||
"PROPERTY_UV": "UV-Beständigkeit",
|
||||
"PROPERTY_WEATHER": "Außeneinsatz",
|
||||
"PROPERTY_RIGIDITY": "Steifigkeit",
|
||||
@@ -132,6 +225,21 @@
|
||||
"PROPERTY_LOW": "Niedrig",
|
||||
"PROPERTY_RIGID": "Steif",
|
||||
"PROPERTY_FLEXIBLE": "Flexibel",
|
||||
"QUANTITY": "Menge",
|
||||
"GO_TO_CHECKOUT": "Zum Checkout",
|
||||
"IN_CART_SHORT": "Im Warenkorb x{{count}}",
|
||||
"IN_CART_LONG": "Bereits im Warenkorb x{{count}}",
|
||||
"DESCRIPTION_TITLE": "Beschreibung",
|
||||
"CART_TITLE": "Warenkorb",
|
||||
"CART_SUMMARY_TITLE": "Aktuelle Übersicht",
|
||||
"CART_LOADING": "Warenkorb wird geladen.",
|
||||
"CART_EMPTY": "Der Warenkorb ist leer. Fügen Sie ein Produkt hinzu.",
|
||||
"CART_SUBTOTAL": "Zwischensumme Produkte",
|
||||
"CART_SHIPPING": "Versand",
|
||||
"CART_TOTAL": "Geschätzte Gesamtsumme",
|
||||
"CLEAR_CART": "Leeren",
|
||||
"REMOVE": "Entfernen",
|
||||
"CART_UPDATE_ERROR": "Der Warenkorb konnte nicht aktualisiert werden. Bitte erneut versuchen.",
|
||||
"ALL_CATEGORIES": "Alle Kategorien",
|
||||
"CATEGORIES": {
|
||||
"FILAMENTS": "Filamente",
|
||||
@@ -400,6 +508,8 @@
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.",
|
||||
"CAD_SERVICE": "CAD-Service",
|
||||
"CAD_REQUEST_REF": "bezogen auf Kontaktanfrage #{{id}}",
|
||||
"CONTACT_INFO": "Kontaktinformationen",
|
||||
"BILLING_ADDR": "Rechnungsadresse",
|
||||
"SHIPPING_ADDR": "Lieferadresse",
|
||||
@@ -553,6 +663,13 @@
|
||||
"ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.",
|
||||
"ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.",
|
||||
"ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.",
|
||||
"CAD_SERVICE": "CAD-Service ({{hours}}h)",
|
||||
"ITEMS_TITLE": "Bestellartikel",
|
||||
"ORDER_TYPE_LABEL": "Bestelltyp",
|
||||
"ITEM_COUNT": "Positionen",
|
||||
"TYPE_SHOP": "Shop",
|
||||
"TYPE_CALCULATOR": "Rechner",
|
||||
"TYPE_MIXED": "Gemischt",
|
||||
"NOT_AVAILABLE": "N/V"
|
||||
},
|
||||
"DROPZONE": {
|
||||
|
||||
@@ -39,6 +39,72 @@
|
||||
"TERMS": "Terms & Conditions",
|
||||
"CONTACT": "Contact Us"
|
||||
},
|
||||
"SEO": {
|
||||
"DEFAULT": {
|
||||
"TITLE": "3D fab | Custom 3D Printing",
|
||||
"DESCRIPTION": "Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs."
|
||||
},
|
||||
"ROUTES": {
|
||||
"HOME": {
|
||||
"TITLE": "Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D fab",
|
||||
"DESCRIPTION": "Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files."
|
||||
},
|
||||
"CALCULATOR": {
|
||||
"TITLE": "3D Printing Quote Calculator | 3D fab",
|
||||
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
|
||||
"BASIC": {
|
||||
"TITLE": "Basic 3D Printing Calculator | 3D fab",
|
||||
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow."
|
||||
},
|
||||
"ADVANCED": {
|
||||
"TITLE": "Advanced 3D Printing Calculator | 3D fab",
|
||||
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing."
|
||||
}
|
||||
},
|
||||
"SHOP": {
|
||||
"TITLE": "3D fab Shop",
|
||||
"DESCRIPTION": "Catalog of 3D printed products, technical accessories and practical ready-to-use solutions.",
|
||||
"CATEGORY_TITLE": "Shop Category | 3D fab",
|
||||
"CATEGORY_DESCRIPTION": "Browse products in this category, available variants and technical 3D printed solutions.",
|
||||
"PRODUCT_TITLE": "Product | 3D fab",
|
||||
"PRODUCT_DESCRIPTION": "Discover details, materials, variants and availability for the selected product in the 3D fab shop."
|
||||
},
|
||||
"MATERIALS": {
|
||||
"TITLE": "Quality and Materials | 3D fab",
|
||||
"DESCRIPTION": "Compare 3D printing materials with interactive radar charts, technical properties and cited sources."
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "About Us | 3D fab",
|
||||
"DESCRIPTION": "Learn more about the 3D fab team and our 3D printing lab for prototypes, spare parts and custom production."
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Contact | 3D fab",
|
||||
"DESCRIPTION": "Request information, custom quotes or technical support for your 3D printing project."
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY": {
|
||||
"TITLE": "Privacy Policy | 3D fab",
|
||||
"DESCRIPTION": "3D fab privacy policy: data processing, purposes and contact details."
|
||||
},
|
||||
"TERMS": {
|
||||
"TITLE": "Terms and Conditions | 3D fab",
|
||||
"DESCRIPTION": "Terms and conditions for the 3D printing service and instant quote calculator."
|
||||
}
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout | 3D fab",
|
||||
"DESCRIPTION": "Complete your request and confirm the details for your 3D printing order."
|
||||
},
|
||||
"ORDER": {
|
||||
"TITLE": "Order | 3D fab",
|
||||
"DESCRIPTION": "Review your order summary and the status of your 3D printing request."
|
||||
},
|
||||
"ADMIN": {
|
||||
"TITLE": "Admin | 3D fab",
|
||||
"DESCRIPTION": "Restricted 3D fab administration area."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CALC": {
|
||||
"TITLE": "3D Print Calculator",
|
||||
"SUBTITLE": "Upload your 3D file (STL, 3MF, STEP...) and get an instant estimate of costs and print time.",
|
||||
@@ -99,6 +165,7 @@
|
||||
"SHOP": {
|
||||
"TITLE": "Technical solutions",
|
||||
"SUBTITLE": "Ready-made products solving practical problems",
|
||||
"HERO_EYEBROW": "Technical shop",
|
||||
"WIP_EYEBROW": "Work in progress",
|
||||
"WIP_TITLE": "Shop under construction",
|
||||
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
|
||||
@@ -109,6 +176,7 @@
|
||||
"CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.",
|
||||
"ADD_CART": "Add to Cart",
|
||||
"ADDING": "Adding to cart",
|
||||
"ADD_SUCCESS": "Product added to cart.",
|
||||
"BACK": "Back to Shop",
|
||||
"NOT_FOUND": "Product not found.",
|
||||
"DETAILS": "Details",
|
||||
@@ -116,14 +184,39 @@
|
||||
"SUCCESS_TITLE": "Added to cart",
|
||||
"SUCCESS_DESC": "The product has been added to the cart.",
|
||||
"CONTINUE": "Continue",
|
||||
"VIEW_ALL": "View the full shop",
|
||||
"CATALOG_LABEL": "Catalog",
|
||||
"CATALOG_TITLE": "All products",
|
||||
"CATALOG_META_DESCRIPTION": "Discover 3D printed products, technical accessories, and ready-to-use solutions with the same checkout as the calculator.",
|
||||
"CUSTOM_PART_CTA": "Can't find what you're looking for? Request a custom part.",
|
||||
"CATEGORY_META": "{{count}} products available in this category",
|
||||
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||
"CATEGORY_PANEL_TITLE": "Categories",
|
||||
"SELECTED_CATEGORY": "Selected category",
|
||||
"ITEMS_FOUND": "products",
|
||||
"EMPTY_CATEGORY": "No products are currently available in this category.",
|
||||
"FEATURED_KICKER": "Featured",
|
||||
"FEATURED_TITLE": "Products worth watching",
|
||||
"FEATURED_BADGE": "Featured",
|
||||
"HIGHLIGHT_PRODUCTS": "Products",
|
||||
"HIGHLIGHT_CART": "In cart",
|
||||
"HIGHLIGHT_READY": "Preview",
|
||||
"PRICE_FROM": "Price from",
|
||||
"MODEL_OPEN": "Open 3D view",
|
||||
"MODEL_CLOSE": "Close 3D view",
|
||||
"MODEL_3D": "3D preview",
|
||||
"MODEL_TITLE": "Model preview",
|
||||
"MODEL_LOADING": "Loading the 3D model.",
|
||||
"MODEL_UNAVAILABLE": "3D preview unavailable.",
|
||||
"PREVIOUS_IMAGE": "Previous image",
|
||||
"NEXT_IMAGE": "Next image",
|
||||
"BREADCRUMB_ROOT": "Shop",
|
||||
"PRICE_LABEL": "Price",
|
||||
"EXCERPT_FALLBACK": "Product page coming soon.",
|
||||
"SELECT_MATERIAL": "Material",
|
||||
"SELECT_COLOR": "Color",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
||||
"VARIANT": "Variant",
|
||||
"PROPERTY_UV": "UV resistance",
|
||||
"PROPERTY_WEATHER": "Outdoor use",
|
||||
"PROPERTY_RIGIDITY": "Rigidity",
|
||||
@@ -132,6 +225,21 @@
|
||||
"PROPERTY_LOW": "Low",
|
||||
"PROPERTY_RIGID": "Rigid",
|
||||
"PROPERTY_FLEXIBLE": "Flexible",
|
||||
"QUANTITY": "Quantity",
|
||||
"GO_TO_CHECKOUT": "Go to checkout",
|
||||
"IN_CART_SHORT": "In cart x{{count}}",
|
||||
"IN_CART_LONG": "Already in cart x{{count}}",
|
||||
"DESCRIPTION_TITLE": "Description",
|
||||
"CART_TITLE": "Cart",
|
||||
"CART_SUMMARY_TITLE": "Current summary",
|
||||
"CART_LOADING": "Loading cart.",
|
||||
"CART_EMPTY": "Your cart is empty. Add a product.",
|
||||
"CART_SUBTOTAL": "Products subtotal",
|
||||
"CART_SHIPPING": "Shipping",
|
||||
"CART_TOTAL": "Estimated total",
|
||||
"CLEAR_CART": "Clear",
|
||||
"REMOVE": "Remove",
|
||||
"CART_UPDATE_ERROR": "We couldn't update the cart. Please try again.",
|
||||
"ALL_CATEGORIES": "All categories",
|
||||
"CATEGORIES": {
|
||||
"FILAMENTS": "Filaments",
|
||||
@@ -400,6 +508,8 @@
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
|
||||
"CAD_SERVICE": "CAD service",
|
||||
"CAD_REQUEST_REF": "related to contact request #{{id}}",
|
||||
"CONTACT_INFO": "Contact Information",
|
||||
"BILLING_ADDR": "Billing Address",
|
||||
"SHIPPING_ADDR": "Shipping Address",
|
||||
@@ -553,6 +663,13 @@
|
||||
"ERR_ID_NOT_FOUND": "Order ID not found.",
|
||||
"ERR_LOAD_ORDER": "Failed to load order details.",
|
||||
"ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.",
|
||||
"CAD_SERVICE": "CAD service ({{hours}}h)",
|
||||
"ITEMS_TITLE": "Order items",
|
||||
"ORDER_TYPE_LABEL": "Order type",
|
||||
"ITEM_COUNT": "Lines",
|
||||
"TYPE_SHOP": "Shop",
|
||||
"TYPE_CALCULATOR": "Calculator",
|
||||
"TYPE_MIXED": "Mixed",
|
||||
"NOT_AVAILABLE": "N/A"
|
||||
},
|
||||
"DROPZONE": {
|
||||
|
||||
@@ -13,6 +13,72 @@
|
||||
"TERMS": "Conditions générales",
|
||||
"CONTACT": "Contactez-nous"
|
||||
},
|
||||
"SEO": {
|
||||
"DEFAULT": {
|
||||
"TITLE": "3D fab | Impression 3D sur mesure",
|
||||
"DESCRIPTION": "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries."
|
||||
},
|
||||
"ROUTES": {
|
||||
"HOME": {
|
||||
"TITLE": "Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D fab",
|
||||
"DESCRIPTION": "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL."
|
||||
},
|
||||
"CALCULATOR": {
|
||||
"TITLE": "Calculateur de devis impression 3D | 3D fab",
|
||||
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
|
||||
"BASIC": {
|
||||
"TITLE": "Calculateur impression 3D de base | 3D fab",
|
||||
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base."
|
||||
},
|
||||
"ADVANCED": {
|
||||
"TITLE": "Calculateur impression 3D avancé | 3D fab",
|
||||
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing."
|
||||
}
|
||||
},
|
||||
"SHOP": {
|
||||
"TITLE": "Boutique 3D fab",
|
||||
"DESCRIPTION": "Catalogue de produits imprimés en 3D, accessoires techniques et solutions pratiques prêtes à l'emploi.",
|
||||
"CATEGORY_TITLE": "Catégorie boutique | 3D fab",
|
||||
"CATEGORY_DESCRIPTION": "Parcourez les produits de cette catégorie, les variantes disponibles et les solutions techniques imprimées en 3D.",
|
||||
"PRODUCT_TITLE": "Produit | 3D fab",
|
||||
"PRODUCT_DESCRIPTION": "Découvrez les détails, matériaux, variantes et disponibilités du produit sélectionné dans la boutique 3D fab."
|
||||
},
|
||||
"MATERIALS": {
|
||||
"TITLE": "Qualité et matériaux | 3D fab",
|
||||
"DESCRIPTION": "Comparez les matériaux d'impression 3D avec des radar charts interactifs, des propriétés techniques et des sources citées."
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Qui sommes-nous | 3D fab",
|
||||
"DESCRIPTION": "Découvrez l'équipe de 3D fab et notre atelier d'impression 3D pour prototypes, pièces et productions sur mesure."
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Contact | 3D fab",
|
||||
"DESCRIPTION": "Demandez des informations, des devis personnalisés ou un support technique pour votre projet d'impression 3D."
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY": {
|
||||
"TITLE": "Politique de confidentialité | 3D fab",
|
||||
"DESCRIPTION": "Politique de confidentialité de 3D fab : traitement des données, finalités et contacts."
|
||||
},
|
||||
"TERMS": {
|
||||
"TITLE": "Conditions générales | 3D fab",
|
||||
"DESCRIPTION": "Conditions générales du service d'impression 3D et du calculateur de devis."
|
||||
}
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout | 3D fab",
|
||||
"DESCRIPTION": "Finalisez votre demande et confirmez les détails de votre commande d'impression 3D."
|
||||
},
|
||||
"ORDER": {
|
||||
"TITLE": "Commande | 3D fab",
|
||||
"DESCRIPTION": "Consultez le récapitulatif de votre commande et l'état de votre demande d'impression 3D."
|
||||
},
|
||||
"ADMIN": {
|
||||
"TITLE": "Admin | 3D fab",
|
||||
"DESCRIPTION": "Espace d'administration restreint de 3D fab."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HOME": {
|
||||
"HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers",
|
||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||
@@ -163,6 +229,7 @@
|
||||
"SHOP": {
|
||||
"TITLE": "Solutions techniques",
|
||||
"SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques",
|
||||
"HERO_EYEBROW": "Boutique technique",
|
||||
"WIP_EYEBROW": "Work in progress",
|
||||
"WIP_TITLE": "Boutique en préparation",
|
||||
"WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !",
|
||||
@@ -173,6 +240,7 @@
|
||||
"CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.",
|
||||
"ADD_CART": "Ajouter au panier",
|
||||
"ADDING": "Ajout en cours",
|
||||
"ADD_SUCCESS": "Produit ajouté au panier.",
|
||||
"BACK": "Retour à la boutique",
|
||||
"NOT_FOUND": "Produit introuvable.",
|
||||
"DETAILS": "Détails",
|
||||
@@ -180,14 +248,39 @@
|
||||
"SUCCESS_TITLE": "Ajouté au panier",
|
||||
"SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.",
|
||||
"CONTINUE": "Continuer",
|
||||
"VIEW_ALL": "Voir toute la boutique",
|
||||
"CATALOG_LABEL": "Catalogue",
|
||||
"CATALOG_TITLE": "Tous les produits",
|
||||
"CATALOG_META_DESCRIPTION": "Découvrez des produits imprimés en 3D, des accessoires techniques et des solutions prêtes à l'emploi avec le même checkout que le calculateur.",
|
||||
"CUSTOM_PART_CTA": "Vous ne trouvez pas ce que vous cherchez ? Demandez une pièce personnalisée.",
|
||||
"CATEGORY_META": "{{count}} produits disponibles dans cette catégorie",
|
||||
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||
"CATEGORY_PANEL_TITLE": "Catégories",
|
||||
"SELECTED_CATEGORY": "Catégorie sélectionnée",
|
||||
"ITEMS_FOUND": "produits",
|
||||
"EMPTY_CATEGORY": "Aucun produit n'est disponible dans cette catégorie pour le moment.",
|
||||
"FEATURED_KICKER": "À la une",
|
||||
"FEATURED_TITLE": "Produits à surveiller",
|
||||
"FEATURED_BADGE": "À la une",
|
||||
"HIGHLIGHT_PRODUCTS": "Produits",
|
||||
"HIGHLIGHT_CART": "Dans le panier",
|
||||
"HIGHLIGHT_READY": "Aperçu",
|
||||
"PRICE_FROM": "Prix à partir de",
|
||||
"MODEL_OPEN": "Ouvrir la vue 3D",
|
||||
"MODEL_CLOSE": "Fermer la vue 3D",
|
||||
"MODEL_3D": "Aperçu 3D",
|
||||
"MODEL_TITLE": "Aperçu du modèle",
|
||||
"MODEL_LOADING": "Chargement du modèle 3D.",
|
||||
"MODEL_UNAVAILABLE": "Aperçu 3D indisponible.",
|
||||
"PREVIOUS_IMAGE": "Image précédente",
|
||||
"NEXT_IMAGE": "Image suivante",
|
||||
"BREADCRUMB_ROOT": "Boutique",
|
||||
"PRICE_LABEL": "Prix",
|
||||
"EXCERPT_FALLBACK": "Fiche produit en préparation.",
|
||||
"SELECT_MATERIAL": "Matériau",
|
||||
"SELECT_COLOR": "Couleur",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
||||
"VARIANT": "Variante",
|
||||
"PROPERTY_UV": "Résistance UV",
|
||||
"PROPERTY_WEATHER": "Usage extérieur",
|
||||
"PROPERTY_RIGIDITY": "Rigidité",
|
||||
@@ -196,6 +289,21 @@
|
||||
"PROPERTY_LOW": "Faible",
|
||||
"PROPERTY_RIGID": "Rigide",
|
||||
"PROPERTY_FLEXIBLE": "Flexible",
|
||||
"QUANTITY": "Quantité",
|
||||
"GO_TO_CHECKOUT": "Aller au checkout",
|
||||
"IN_CART_SHORT": "Dans le panier x{{count}}",
|
||||
"IN_CART_LONG": "Déjà dans le panier x{{count}}",
|
||||
"DESCRIPTION_TITLE": "Description",
|
||||
"CART_TITLE": "Panier",
|
||||
"CART_SUMMARY_TITLE": "Récapitulatif actuel",
|
||||
"CART_LOADING": "Chargement du panier.",
|
||||
"CART_EMPTY": "Le panier est vide. Ajoutez un produit.",
|
||||
"CART_SUBTOTAL": "Sous-total produits",
|
||||
"CART_SHIPPING": "Expédition",
|
||||
"CART_TOTAL": "Total estimé",
|
||||
"CLEAR_CART": "Vider",
|
||||
"REMOVE": "Supprimer",
|
||||
"CART_UPDATE_ERROR": "Nous n'avons pas réussi à mettre à jour le panier. Réessayez.",
|
||||
"ALL_CATEGORIES": "Toutes les catégories",
|
||||
"CATEGORIES": {
|
||||
"FILAMENTS": "Filaments",
|
||||
@@ -464,6 +572,8 @@
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.",
|
||||
"CAD_SERVICE": "Service CAD",
|
||||
"CAD_REQUEST_REF": "lié à la demande de contact #{{id}}",
|
||||
"CONTACT_INFO": "Informations de contact",
|
||||
"BILLING_ADDR": "Adresse de facturation",
|
||||
"SHIPPING_ADDR": "Adresse de livraison",
|
||||
@@ -559,6 +669,13 @@
|
||||
"ERR_ID_NOT_FOUND": "ID de commande introuvable.",
|
||||
"ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.",
|
||||
"ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.",
|
||||
"CAD_SERVICE": "Service CAD ({{hours}}h)",
|
||||
"ITEMS_TITLE": "Articles de la commande",
|
||||
"ORDER_TYPE_LABEL": "Type de commande",
|
||||
"ITEM_COUNT": "Lignes",
|
||||
"TYPE_SHOP": "Boutique",
|
||||
"TYPE_CALCULATOR": "Calculateur",
|
||||
"TYPE_MIXED": "Mixte",
|
||||
"NOT_AVAILABLE": "N/D"
|
||||
},
|
||||
"DROPZONE": {
|
||||
|
||||
@@ -13,6 +13,72 @@
|
||||
"TERMS": "Termini & Condizioni",
|
||||
"CONTACT": "Contattaci"
|
||||
},
|
||||
"SEO": {
|
||||
"DEFAULT": {
|
||||
"TITLE": "3D fab | Stampa 3D su misura",
|
||||
"DESCRIPTION": "Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie."
|
||||
},
|
||||
"ROUTES": {
|
||||
"HOME": {
|
||||
"TITLE": "Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D fab",
|
||||
"DESCRIPTION": "Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL."
|
||||
},
|
||||
"CALCULATOR": {
|
||||
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
|
||||
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
|
||||
"BASIC": {
|
||||
"TITLE": "Calcolatore stampa 3D base | 3D fab",
|
||||
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base."
|
||||
},
|
||||
"ADVANCED": {
|
||||
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab",
|
||||
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale."
|
||||
}
|
||||
},
|
||||
"SHOP": {
|
||||
"TITLE": "Shop 3D fab",
|
||||
"DESCRIPTION": "Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.",
|
||||
"CATEGORY_TITLE": "Categoria Shop | 3D fab",
|
||||
"CATEGORY_DESCRIPTION": "Esplora i prodotti di questa categoria, le varianti disponibili e le soluzioni tecniche stampate in 3D.",
|
||||
"PRODUCT_TITLE": "Prodotto | 3D fab",
|
||||
"PRODUCT_DESCRIPTION": "Scopri dettagli, materiali, varianti e disponibilita del prodotto selezionato nello shop 3D fab."
|
||||
},
|
||||
"MATERIALS": {
|
||||
"TITLE": "Qualita e Materiali | 3D fab",
|
||||
"DESCRIPTION": "Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate."
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Chi siamo | 3D fab",
|
||||
"DESCRIPTION": "Scopri il team 3D fab e il laboratorio di stampa 3D per prototipi, ricambi e produzioni su misura."
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Contatti | 3D fab",
|
||||
"DESCRIPTION": "Richiedi informazioni, preventivi personalizzati o supporto tecnico per il tuo progetto di stampa 3D."
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY": {
|
||||
"TITLE": "Privacy Policy | 3D fab",
|
||||
"DESCRIPTION": "Informativa privacy di 3D fab: trattamento dati, finalita e contatti."
|
||||
},
|
||||
"TERMS": {
|
||||
"TITLE": "Termini e condizioni | 3D fab",
|
||||
"DESCRIPTION": "Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi."
|
||||
}
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout | 3D fab",
|
||||
"DESCRIPTION": "Completa la richiesta e conferma i dati del tuo ordine di stampa 3D."
|
||||
},
|
||||
"ORDER": {
|
||||
"TITLE": "Ordine | 3D fab",
|
||||
"DESCRIPTION": "Consulta il riepilogo del tuo ordine e lo stato della richiesta di stampa 3D."
|
||||
},
|
||||
"ADMIN": {
|
||||
"TITLE": "Admin | 3D fab",
|
||||
"DESCRIPTION": "Area amministrativa riservata di 3D fab."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HOME": {
|
||||
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
|
||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||
@@ -506,6 +572,8 @@
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
|
||||
"CAD_SERVICE": "Servizio CAD",
|
||||
"CAD_REQUEST_REF": "riferito alla richiesta contatto #{{id}}",
|
||||
"CONTACT_INFO": "Informazioni di Contatto",
|
||||
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
||||
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
||||
@@ -601,6 +669,7 @@
|
||||
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
|
||||
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
|
||||
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
|
||||
"CAD_SERVICE": "Servizio CAD ({{hours}}h)",
|
||||
"ITEMS_TITLE": "Articoli dell'ordine",
|
||||
"ORDER_TYPE_LABEL": "Tipo ordine",
|
||||
"ITEM_COUNT": "Righe",
|
||||
|
||||
40
frontend/src/core/request-origin.spec.ts
Normal file
40
frontend/src/core/request-origin.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { resolveRequestOrigin } from './request-origin';
|
||||
|
||||
describe('resolveRequestOrigin', () => {
|
||||
it('prefers forwarded host and protocol when present', () => {
|
||||
expect(
|
||||
resolveRequestOrigin({
|
||||
protocol: 'http',
|
||||
headers: {
|
||||
host: 'internal:4000',
|
||||
'x-forwarded-host': '3d-fab.ch',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
}),
|
||||
).toBe('https://3d-fab.ch');
|
||||
});
|
||||
|
||||
it('falls back to request protocol and host', () => {
|
||||
expect(
|
||||
resolveRequestOrigin({
|
||||
protocol: 'http',
|
||||
headers: {
|
||||
host: 'localhost:4000',
|
||||
},
|
||||
}),
|
||||
).toBe('http://localhost:4000');
|
||||
});
|
||||
|
||||
it('uses the first forwarded value when proxies append multiple entries', () => {
|
||||
expect(
|
||||
resolveRequestOrigin({
|
||||
protocol: 'http',
|
||||
headers: {
|
||||
host: 'internal:4000',
|
||||
'x-forwarded-host': '3d-fab.ch, proxy.local',
|
||||
'x-forwarded-proto': 'https, http',
|
||||
},
|
||||
}),
|
||||
).toBe('https://3d-fab.ch');
|
||||
});
|
||||
});
|
||||
50
frontend/src/core/request-origin.ts
Normal file
50
frontend/src/core/request-origin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type RequestLike = {
|
||||
protocol?: string;
|
||||
get?: (name: string) => string | undefined;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function firstForwardedValue(
|
||||
value: string | string[] | undefined,
|
||||
): string | null {
|
||||
const raw = firstHeaderValue(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRequestOrigin(
|
||||
request: RequestLike | null,
|
||||
): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host =
|
||||
firstForwardedValue(request.headers?.['x-forwarded-host']) ??
|
||||
request.get?.('host') ??
|
||||
firstHeaderValue(request.headers?.['host']);
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forwardedProto = firstForwardedValue(
|
||||
request.headers?.['x-forwarded-proto'],
|
||||
)?.toLowerCase();
|
||||
const protocol = forwardedProto || request.protocol || 'http';
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import express from 'express';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import bootstrap from './main.server';
|
||||
import { resolveRequestOrigin } from './core/request-origin';
|
||||
|
||||
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||
@@ -39,13 +40,14 @@ app.get(
|
||||
* Handle all other requests by rendering the Angular application.
|
||||
*/
|
||||
app.get('**', (req, res, next) => {
|
||||
const { protocol, originalUrl, baseUrl, headers } = req;
|
||||
const { originalUrl, baseUrl } = req;
|
||||
const origin = resolveRequestOrigin(req);
|
||||
|
||||
commonEngine
|
||||
.render({
|
||||
bootstrap,
|
||||
documentFilePath: indexHtml,
|
||||
url: `${protocol}://${headers.host}${originalUrl}`,
|
||||
url: `${origin ?? 'http://localhost:4000'}${originalUrl}`,
|
||||
publicPath: browserDistFolder,
|
||||
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user